Персональный менеджер задач для повседневных дел и личных проектов.
| Версия ТЗ | 0.88 |
| Дата | 2026-10-10 |
| Статус | На обсуждении |
Продвинутый личный таск-менеджер. Ключевая идея — минимальный трением ввод: задача закидывается в систему в одну строку, без выбора проекта, тегов и приоритета. Система сама делает предварительную детализацию, а пользователь позже утверждает или правит её при разборе.
user_id — один пользователь не видит чужого. Сад (0.58) в интерфейсе отключён, но его данные по-прежнему изолированы.user_id = NULL (наследие однопользовательской версии) и глобальные настройки забираются первым вошедшим пользователем.Сценарий А — веб-интерфейс (основной). Разбор стека входящих, детализация и утверждение задач, работа с проектами, планирование, просмотр дерева задач.
Сценарий Б — ИИ-агент через MCP. Агент от имени пользователя добавляет задачи (например, из переписки или диалога с LLM). Большинство AI-задач выполняется на стороне агента; сервер предоставляет MCP-интерфейс к данным.
raw) и ожидают детализации.При попадании задачи в стек система самостоятельно предлагает черновые метаданные (0.40: только оформление задачи, без планирования):
Планирование ИИ не делает (решение 2026-09-22, «0% попадания»): проект, приоритет и оценку длительности LLM не предлагает — их заполняет пользователь.
Требования:
approved) только после явного действия пользователя.- [ ] / - [x]) в отображаемом описании кликабельны: клик переключает пункт и сохраняет статус в markdown описания.README.md из репозитория (с подписью-ссылкой на источник). Хост не обязан быть GitHub/GitLab — селф-хостед системы ищутся эвристикой (API github/gitlab/gitea + raw-пути web-интерфейсов, дефолтная ветка HEAD); не нашли — остаётся ссылка. Сырой текст в БД не меняется; кэш прочтённого README — 10 минут. Пользователю подсказывается об опции: в форме создания задачи (подпись редактора) и в пустом описании/инлайн-правке на странице задачи. У проекта этого механизма больше нет (0.67): ссылка на репозиторий живёт отдельным полем, а README показывается вторым описанием (см. 3.8).target="_blank", rel="noopener") — PWA не ловит навигацию в своё окно.Тип задачи: каждая задача имеет тип — разовая или регулярная (повторяющаяся; см. 3.5).
Статусы выполнения:
| Статус | Смысл |
|---|---|
to_do — К выполнению |
Принята в работу, ожидает выполнения |
in_progress — В работе |
Выполняется сейчас |
done — Завершено (закрыто) |
Выполнена |
cancelled — Отменено |
Была актуальной, затем перестала быть актуальной. Не равно удалению — задача сохраняется, т.к. может снова стать актуальной |
deferred — Отложено |
На текущий момент не нужна или не в приоритете; возможно, позже это изменится |
cancelled и deferred — обратимы: задача может быть возвращена в to_do.raw → approved) — отдельная плоскость, не статус выполнения: «сырая» задача из стека получает статус только после утверждения.accept_state: нет | pending | accepted | rejected) — вторая отдельная плоскость (3.20): закрытие задачи ИИ-агентом помечается «ждёт приёмки», владелец принимает работу или возвращает задачу в работу. Набор статусов выполнения при этом не меняется.Дедлайны — двух видов:
Прогнозирование времени выполнения — система оценивает длительность задачи (на основе истории завершённых задач; алгоритм уточняется).
Бюджет — опциональный, на задачу; есть не у каждой задачи:
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.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 не проходят.GnPageHeader) одной группой; отдельной строкой под шапкой остались только табы статуса. На узком экране группа переносится по строкам сама (переключатель строкой, поле — следующей).parent_task_id) в модели данных сохраняется.timeAgo, что у свежести проекта, 0.69); полная дата — в подсказке. Это контекст, а не метаданные, поэтому не бейдж, а приглушённый текст; на странице задачи дата добавления как была — в сайдбаре «Даты» в полном формате. С 0.76 живёт в подвале карточки (слот footer китового GnCard), а не в ряду бейджей: подвал отделён чертой 1px и прижат к низу карточки, поэтому в сетке списка он у всех карточек на одной линии. Иконка рядом с текстом идёт с китовым классом normalize — без него глиф иконочного шрифта садится на 1.7px выше середины строки (в подвале карточки сама поправка снята с 0.83 — см. 3.19).GnSelect кита, первым пунктом «— без проекта —», текущий проект выбран по умолчанию; задача в архивном проекте тоже остаётся выбранной, хотя в справочнике активных проектов её нет. Перенос уходит тем же PATCH /api/tasks/{id} с project_id, что и правка поля «Проект» на странице задачи: бэкенд проверяет, что проект принадлежит пользователю, и рассылает task.changed. XP и монеты за перенос не начисляются — они висят только на переходе в «завершено». Успех подтверждается тостом, список перечитывается сразу (moved), поэтому задача тут же уходит со страницы прежнего проекта. В 0.83 пункт сокращён до «В другой проект»: надпись «Переместить в проект» в китовом меню шириной 220px рвалась на две строки.ph-folder-dotted), счётчики в заголовках — по всему списку; заголовки секций показываются только когда пустые проекты есть, иначе список выглядит как раньше. Порядок применяется до пагинации (3.6), чтобы секция не разъезжалась по страницам.100%/M × N), а не процентом кита: у процента округление до целого, и на границе клетки оставалась бы щель. Трек на карточке тоньше китовских 18px — 8px (0.68): полоса здесь подложка под счётчиком, а не самостоятельный элемент. Проект закрыт целиком — полоса и счётчик уходят в зелёный кита. Цвет проекта в тексте не используется (тёмная ступень цветных рядов и тёмный край нейтральной шкалы, 0.72 — нечитаемы как текст) — им закрашена только полоса.<a>: клик в любом месте, фокус с клавиатуры, «открыть в новой вкладке»), заголовок внутри — не ссылка (вложенные ссылки невалидны). В архиве (3.8.1) кнопка «Открыть» остаётся — там рядом действия восстановления/удаления.#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. До чёрного шкала не доходит намеренно: чёрная метка на тёмной карточке кита не видна, то есть была бы выбором без результата (раньше это называлось ценой полноты шкалы).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) с хостом): не похожа на репозиторий — прокси честно ответит «не найден». Существующие заметки-ссылки в БД не переписываются: ссылку нужно указать в новом поле.pinned — важные проекты держатся наверху списка и не уезжают вниз с пагинацией. Закреплённые живут отдельной секцией «Закреплённые» вверху (иконка ph-push-pin, янтарный бейдж-счётчик), независимо от наличия задач: закреплённый проект без задач остаётся в «Закреплённых», а не в «Без задач». Переключателей два, поле одно: булавка в правом верхнем углу карточки списка — рядом с бейджем актуальности (шапка сдвигается левее), видна всегда, в покое не приглушена; и строка «Закреплён» в параметрах страницы проекта (значение кликабельно, как у остальных строк). Место булавки переставлено в 0.67: в правом нижнем углу карточки она читалась частью подвала, а на странице проекта её и вовсе не находили — поэтому у имени проекта появилась кнопка-булавка рядом с названием (в архиве скрыта: страница только для чтения, состояние видно строкой «Закреплён»; строка в параметрах остаётся — второй переключатель). Новый проект создаётся незакреплённым. В MCP — поле pinned у update_project (3.10).created_at или done_at); правки самого проекта (заметка, цвет, статус) активностью не считаются — сортировка про работу над проектом, а не про правку его карточки. Проект без задач стоит в конце своей группы. Порядок применяется до пагинации (3.6), а счётчики секций и показ заголовков считаются по всему списку, не по странице: иначе на второй странице заголовки то появлялись бы, то исчезали.created_at/done_at задач; правки самого проекта не считаются); полная дата — в подсказке. Срок при этом стоит справа — важное в одном и том же углу у всех карточек, независимо от того, есть ли свежесть. Сутки календарные, ниже недели — дни, дальше округление вниз до крупной единицы (45 дней — это уже месяцы, 400 — годы). Появились плюральные формы: их выбирает правило самой локали (Intl.PluralRules) — «2 дня», а не «2 день»; в словаре формы записаны одной строкой через «|» (ru/uk — три, en — две), выбор формы в коде не хардкодится. Число при единственном числе остаётся («1 месяц назад»). У проекта без задач свежести нет — строка пустая, но место подвала сохраняется, чтобы низ карточек в ряду не разъезжался.id не восстанавливается осознанно: имя тега по id известно только после загрузки справочника.done_at, появления по created_at. В содержимое сайдбара сетка влезает без горизонтальной прокрутки: ячейки 9px с зазором 2px (26 недель — 284px; у статистики ячейки 12px, но там карточка во всю ширину), легенда шкалы идёт под сеткой.Проект, с которым пользователь закончил работу, отправляется в архив — один архив на все проекты, отдельных архивов задач нет.
Анти-прокрастинационный режим: пользователь указывает доступное время в часах (например, «3», можно 0.5), система предлагает подходящие задачи; предложение — те же карточки, что в списке задач.
?via=options) и берёт её в работу на странице задачи; закрытие задачи, открытой из выдачи, помечается «выбрал и сделал».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;create_task без проекта содержит подсказку.gnt_..., показывается один раз при создании; в БД только sha256-хэш). Там же ссылка на страницу-инструкцию по MCP (/mcp-help): что реализует, адрес сервера (совпадает с адресом приложения), токен, примеры подключения (Claude Code, универсальный JSON).MCP_TOKEN удалена — старые конфиги агентов получат 401, пока владелец не создаст токен в «Настройках». С 0.42 Bearer-токен принимается и на REST /api/* — им ходит браузерное расширение (3.18).update_task/complete_task) идёт по тому же общему пути, что и в UI: спавн регулярной, XP, монеты (3.5, 3.13); в открытых вкладках — скромный тост о награде (3.14). Растение больше не выдаётся — сад отключён в 0.58.is_user — по умолчанию действие считается агентским, is_user=true объявляет действие от имени владельца (тот же смысл у заголовка X-Actor: user на REST). Актор и канал (ui / api / mcp) попадают в журнал task_events. Без флага агент изменяет и закрывает только задачи, помеченные «доступно для ИИ-агента», и закрывает их в режим приёмки; флаг он может поставить лишь при создании задачи.projects.summary — краткое резюме заметки (суть + полезные признаки для классификации задач). Суммаризация генерируется той же LLM фоном при создании проекта с заметкой и при правке заметки (кэш в БД); без суммаризации в контекст идёт обрезка заметки./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./api/projects/{id}/readme, бэкенд читает repository_url проекта и тянет сырой README перебором известных путей (см. 3.3), кэшируя прочтённое на 10 минут (в том числе негативный результат — чтобы не долбить git-хост). Привязка к проекту, а не параметр ?url=: открытым прокси для чтения произвольных адресов эндпоинт не становится. Ссылку на репозиторий фронт не передаёт — её знает только бэкенд. По той же причине, что и у фавикона, сторонние сервисы чтения README не используются.profile.locale из SSO-профиля, scope profile).app_settings, ключ language), пустое значение = следовать SSO.Маленькая дополнительная награда за выполнение задач — мотивация без негативных последствий. Принцип только позитив: XP только начисляется, ничего не сгорает и не отнимается; стрики/серии (которые «ломаются» при пропуске) не используются.
xp_events (поле kind разделяет события: task — закрытие, create_task / create_project — создание).task_id = NULL — удаление задачи или проекта награду не отнимает.POST /api/xp/daily — раз в сутки (граница дня UTC) начисляется 50 монет (только монеты, XP нет), повторные визиты в те же сутки молча пропускаются. Тост «+50 монет» только в день первого визита.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 не ломаются).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). Пока сад отключён, тратить монеты негде: они копятся и пригодятся, когда сад вернётся.xp_events и показываются на «Достижениях» (0.58) — общий блок с садом (components/AchievementsPanel.vue). Описание по клику (0.49): карточка-ачивка — триггер GnPopover кита, клик открывает панель с кратким описанием условия (progress.achHint, i18n ×3; тултип по hover не годится — не работает на тач-экранах).earned_via) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой; XP при этом не меняется.xp_events и дат задач, без новых эндпоинтов.progress.about./garden редиректит на /achievements, код сохранён для возврата (по прецеденту представления «дерево», 3.6). Растения больше не выдаются: GardenItem создаётся лениво — в ensure_garden(), которую зовёт только GET /api/garden; UI этот эндпоинт не вызывает, поэтому выдача прекращается сама. Модель GardenItem, журнал coin_events и накопленная редкость (xp_events.rarity) сохраняются — сад вернётся с прежним состоянием. Всё описанное ниже — исторический блок (как сад устроен), актуальны только XP, уровни, монеты и ачивки.nearest масштабирование без сглаживания, целочисленный «нативный пиксель» (матрицы 16px × 2 = 32px на клетку). Все спрайты (тайлы травы/дикой земли, изгородь, домик, 13 видов растений × 3 стадии, декорации, кольца редкости, блёстки) генерируются из пиксель-матриц прямо в коде — внешних файлов-ассетов нет. Рендерер (src/game/GardenRenderer.ts) — обычный класс, не оборачивается во Vue-reactive (deep-прокси ломает внутренние identity-проверки Pixi); Pixi загружается динамическим импортом (отдельный чанк). Инвентарь, поповер и drag-ghost — DOM поверх канваса.xp_events, визуально — золото + бейдж.garden_items.item_key) — открытые позже виды не перерисовывают старые растения. Растение привязано к событию XP (переживает удаление задачи).garden_items.x/y = NULL) — место покупки всегда видно пользователю. Из инвентаря декорация перетаскивается на карту; перетаскивание размещённой декорации обратно на полосу инвентаря убирает её с карты (растения в инвентарь вернуть нельзя — сад только растёт).Данные меняются не только из 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).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, слушателей у события нет).task.claimed, task.review) уходят системным уведомлением в закрытое приложение, а напоминания приходят только так. Шину это не заменяет: мгновенный refetch открытого интерфейса остаётся за SSE, а при видимой вкладке системное уведомление не показывается — иначе оно дублировало бы тост.X-Earned-XP, ТЗ 3.13); SSE-эхо и эхо соседней вкладки молчат — окно активности 3 с (localStorage). Соседние вкладки показывают скромный тост «+N XP» без конфетти.Приложение устанавливается на устройство как приложение (Chrome/Android; вопрос оффлайн-режима данных — открытый вопрос 8.4, пока приложение всегда онлайн).
#16161e — фон и theme-color, чтобы не мигало белым при запуске и не зеленела шапка окна), иконки 192/512 + maskable-512, id/start_url = /, язык ru.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 отдаётся без кэша.autoUpdate); SSE-соединение при обновлении рвётся — reconnect покрывает (3.14).devOptions выключен) — PWA проверяется на прод-сборке (dist), не на dev-сервере; для отладки push в dev воркер поднимается явным флагом VITE_PWA_DEV=1 (0.88, 3.21).hover:none, pointer:coarse) — иначе iOS зумит страницу при фокусе и зум застревает;100dvh вместо 100vh у drawer'ов, мобильного меню и модалок (низ уезжал под панель Safari и индикатор «домой»); на старых Safari остаётся 100vh;apple-mobile-web-app-* меты: standalone для «На экран Домой» (до iOS 16.4 манифест не читается), black-translucent статус-бар, короткий тайтл;:active-состояния);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./login вне навигационной оболочки: карточка кита (GnCard, 460px) с логотипом, названием и единственной кнопкой «Войти» — инициация SSO-флоу (/auth/login с return_to). Пока /auth/me не ответил, в карточке только лого и лоадер — кнопка не вспыхивает перед редиректом уже залогиненного; тот же вид, что на экране входа gnexus-creds./auth/me → 401); после логина возвращается на исходный адрес. Истёкшая сессия в API/SSE-коде тоже ведёт на /login, а не на бэкендовый SSO-флоу./auth/logout после очистки сессии редиректит на /login (не оставляет пользователя на JSON-ответе)./auth/me не считается «не залогинен» — оффлайн-оболочка открывается без редиректа.Расширение для Chrome/Firefox (MV3): клик по иконке в трее браузера → попап с двумя вкладками — «Добавить» и «В работе». Быстрый захват входящих (3.1) вне страницы сервиса.
/api/tasks + PATCH project_id, как в веб-форме). Фидбек — анимация-галочка и тост «Задача добавлена (+N XP)»; поле очищается, выбранный проект сохраняется; кнопка «Открыть GNexus Tasks» ведёт на главную.in_progress; название, чип проекта, метка просрочки, теги); клик открывает задачу в сервисе, действий в попапе нет./api/* с Authorization: Bearer <MCP-токен>. Токен вставляется один раз из «Настроек → Токены MCP», хранится в chrome.storage.local; отзыв токена отключает расширение (как и агентов, 3.10).http://localhost:15134; произвольный адрес задаётся в настройках попапа и запрашивает host-разрешение (optional_host_permissions + chrome.permissions.request).chrome.storage.local и восстанавливается при следующем открытии; после успешной отправки текст очищается (как в веб-форме)./extension): скачивание ZIP-сборки расширения (раздаётся статикой фронта; в Docker-образе фронта собирается из extension/), шаги загрузки unpacked и подключения токена. Из установленного PWA пункт меню скрыт (display-mode: standalone) — по прямому адресу страница показывает пояснение.--gn-border-color-muted, мелкие контролы/инсеты — 3px; темы Verdant/Ember/Mono и дизайн-токены --gn-* доступны, но не используются пока в gntodo)..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 кита в приложении снят), поэтому карточка задачи раскладывается колонкой, а остаток высоты отдаётся содержимому — подвал прижат к низу и у карточек одного ряда стоит на одной линии.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 перенос не подхватывает). На широком экране переносить нечего — десктопная раскладка не изменилась..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..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; на мобильных шапка контекста не создаёт, шторка попадает в корневой контекст и лежит выше затемнения.frontend/src/viewport.ts), чтобы поведения на одной ширине не разошлись. Быстрый захват строкой в стеке (3.1) не форма: он очищает поле и остаётся на месте.dropdownClamp.ts) ставило меню инлайновый right: 0, но китовый .dropdown-menu{left: 0} при этом продолжал действовать — оба края заданы, и при min-width: 220px меню оставалось у левого края триггера: у правой колонки карточек (три колонки на 1440px) оно выезжало за вьюпорт на всю свою ширину (right 1593 при 1440). Теперь при развороте влево выставляется и left: auto.ph-dots-three-outline — как в карточках задач, стека и на странице проекта (на странице задачи стоял залитый ph-dots-three)..tabs-list{overflow-x:auto}), но видимая полоса тут только шумит — обрезанный шестой таб и так читается как «дальше есть». Прокрутка сохранена, спрятана только полоса (scrollbar-width: none + ::-webkit-scrollbar для Safari).left: -5px от коробки контрола, поэтому видимый левый край свитча выходил на 3–5px левее соседей по ряду. Сдвиг — компенсация выступа, а не отступ между элементами.footer с чертой во всю ширину. Теперь подвал проекта тоже в слоте footer: содержимое забирает свободную высоту карточки, подвал ложится вниз и отделён чертой во всю ширину, слева свежесть, справа срок — как в карточке задачи. Сама полоса (черта, раскладка, мягкий текст мельче основного, высота пустой строки) стала общей для обеих карточек и живёт в kit-overrides.css: правила были одни и те же в двух местах. Пустой подвал (проект без задач и без срока) держит высоту строки — иначе черта у соседей по ряду встала бы на разной высоте. Скелетон списка повторяет эту полосу, чтобы загрузка не дёргала раскладку..card-footer — 8px сверху против 15px снизу, поэтому «2 недели назад» висело на 3.5px выше середины полосы. Отступы выровнены (12px сверху и снизу), полоса от этого выше прежней на пиксель..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, либо отдельный класс без поправки).normalize, глиф иконочного шрифта садился выше середины строки и читался подвешенным — как в карточке задачи, где этот класс стоит с 0.76.--gn-border-width-accent, тот же шаг, что у полосы шапки страницы) и цвета проекта — того же, что метка у имени и заливка прогресса; у проекта без цвета грань берёт акцент кита, как и его прогресс. У карточки задачи рамка осталась ровной 2px, у задачи без изменений. Тот же корешок у карточки проекта в архиве и у заготовок списков (иначе при загрузке карточка дёрнула бы ширину содержимого). Наведение подкрашивает рамку, но не корешок: цвет грани — принадлежность проекту, а не состояние карточки.projects.noActivity)..tabs-list в kit-overrides.css. Прокрутка сохранена (0.80).GnModal + GnSelect кита, с подписью, какая именно задача переезжает. Акцент отдан действию («Переместить»), а «Отмена» осталась контурной: в прочих диалогах приложения действие красится в danger/success, а primary берёт на себя отмена — у переноса своего смыслового варианта нет.!important-войн с китом: только каскадное переопределение поверх публичных классов.--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: фон --gn-surface-page впечатан, потому что прозрачность iOS рисует чёрным (углы холста скруглены, за ними — ничего). До 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, и текст заголовка уезжал вправо от остального содержимого. Ссылка стека носит собственный card-link.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) и рисуются равными каналами, минуя общий зажим светлоты цветных рядов.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-атрибут родителя.@click висит на корне GnCard (кит раскрывает attrs на <article class="card">), а обработчик пропускает клики, у которых event.target.closest('a, button, input, select, textarea, label') что-то нашёл, и клики по выделенному тексту (window.getSelection() непустой — иначе сниппет не скопировать).width/height: .65em, border-radius: 50%) в четырёх местах (карточка списка, шапка проекта, бейдж проекта у задачи, строки проектов дашборда) — иначе стиль дублировался бы по файлам. Нет цвета — нет и кружка. Отступ справа 0.25em (0.73): кружок всегда стоит вплотную к имени проекта — в бейдже задачи он прилипал к нему. Метка ProjectMark этот отступ снимает (там зазор задаёт flex-gap), поэтому интервал перед фавиконом не удваивается.GnProgress — одна заливка, GnProgressStages — сетка этапов), поэтому карточка проекта рисует клетки насечками поверх трека (::after трека с repeating-linear-gradient, шаг 100%/M), а сама полоса остаётся GnProgress — с ролью progressbar, aria-valuenow/max и анимацией ширины. Переменные оформления (--card-color, --task-cells, --done-cells) вешаются на корень карточки, а не на полосу: кит в GnProgress ставит свой style после ...attrs, и переданный извне style теряется.Агент ходит персональным токеном владельца (3.10), поэтому до 0.85 его действия были неотличимы от пользовательских, а взять задачу в работу он мог только «вслепую». Раздел вводит три вещи: мандат (какие задачи агенту можно), взятие (чтобы двое не делали одну работу) и журнал (кто, что и когда — навсегда в БД).
1. Доступность задачи. У задачи есть флаг ai_eligible — «доступно для ИИ-агента».
created_by_kind = agent, created_by_name — label токена): в интерфейсе у них бейдж, а список по этому признаку фильтруется (иначе признак пришлось бы вычислять join'ом).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), как и второго экземпляра регулярной.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).task_id при удалении обнуляется, но заголовок сохранён снапшотом — журнал читается как история, а не как список живых задач.5. Передача задачи агенту вручную (0.87). Агент находит задачи сам, но владельцу часто нужно отдать конкретную: на странице задачи в меню «⋯» есть пункт «Промпт для ИИ» (подпись короткая намеренно — в китовом меню 220px длинная рвётся на две строки, как в 0.83; само окно называется «Промпт для ИИ-агента»). Он собирает текст задачи — id, заголовок, статус, тип, приоритет, оценку и факт, деньги, срок, проект, теги, родителя, пометку доступности, живую аренду, описание, черновое предложение детализации, подзадачи и ссылку на страницу — и показывает его в окне, где текст можно поправить; копирует его публичная кнопка кита (GnCopyButton), так что в буфер попадает ровно то, что в окне.
Актор действия. У изменяющих MCP-тулов есть флаг is_user: по умолчанию действие считается агентским, is_user=true — «действую от имени владельца» (на REST тот же смысл у заголовка X-Actor: user, им объявляет себя браузерное расширение). В журнале у такого действия актор — владелец, но канал остаётся mcp/api: видно, что действие заявлено от его имени и пришло от агента.
Это не охрана. ai_eligible — не контроль доступа, а рамка делегирования. Держатель валидного токена и так действует как владелец (токен — его принципал), и объявить is_user=true он вправе в любой момент. Ценность мандата — в видимости и в защите от случайности: агент не тронет задачу, которую владелец ему не поручал, а всё, что он сделал, видно в журнале. Строить на ai_eligible разграничение прав нельзя.
Работа ИИ-агента идёт, пока приложение закрыто, и до 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). Это ровно те события, о которых владелец иначе не узнает, пока не откроет приложение.
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 не пишутся: он про действия с задачами, а не про доставку.
| Требование | Значение |
|---|---|
| Архитектура | Клиент-серверное; 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:
ipaddress.is_global) — private/loopback/link-local/reserved/CGNAT (включая метаданные облака 169.254.169.254) отклоняются;content-type обязан быть изображением, а расширение определяется по magic-bytes;X-Content-Type-Options: nosniff и Cache-Control: private, без возможности исполниться как документ.Остаточный риск: между проверкой хоста и соединением HTTP-клиент резолвит имя повторно (DNS-rebinding, TOCTOU); полное закрытие требует соединения по IP с подменой Host/SNI и ломает проверку TLS-сертификата. Для личного приложения риск принят — основной вектор (публичный URL → метаданные облака) закрыт.
Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах, саде и настройках есть user_id (nullable — NULL забирается первым вошедшим, см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. Модель сада (0.58) сохранена без изменений — раздел отключён, данные ждут возврата; XP/монеты и rarity продолжают начисляться, как раньше.
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 } — «в течение периода»
recurrence nullable — правило повторения (для task_type = recurring)
time_estimate nullable — прогноз времени (система)
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 и
постоянные ошибки) удаляются лениво, при отправке
История завершённых задач (фактическое время) — источник для прогнозирования длительности.
┌─────────────┐ ┌────────────────────── VPS (docker-compose) ─────────────┐
│ Web SPA │────▶│ API (FastAPI) ──▶ PostgreSQL │
│ Vue 3 + │ │ ▲ │ │
│ gnexus- │ │ │ ├──▶ Сервис автодетализации ──▶ Ollama (модель │
│ ui-kit │ │ │ │ — LLM, модель из конфига) │
└─────────────┘ │ │ ├──▶ Хранилище вложений (файлы) │
▲ │ │ └──▶ MCP-сервер (для ИИ-агентов) │
└─ SSE ───────┤ шина событий (realtime) │
└─────────────────────────────────────────────────────────┘
▲ SSO auth.gnexus.space (клиент gnexus-gauth)
| Этап | Содержание |
|---|---|
| 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) |
| M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) |
Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария.
qwen3.5:2b-q4_K_M (проверена на M2); меняется конфигом (OLLAMA_MODEL) без правок кода. Ollama — внешний сервер, адрес — OLLAMA_BASE_URL.cancelled / deferred: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся.spawned_at), выход из done сбрасывает done_at. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами.GET /api/garden; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных.task_events пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка per_page хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче.