---
project: vibe-coding
type: plan
date: 2026-04-29
status: accepted
supersedes: "{vibe-coding} {plan} ticktick-bot design – 2026-04-10.md"
---

# Tasks Bot — Design Spec v1.0

## Зачем

Задачи рождаются весь день из разных источников (голова, встречи, переписки), но не попадают в систему — нет одного очевидного места для захвата. Существующий вальт хорош для просмотра, но плох для захвата в моменте. TickTick остался для бэклога текстов, для оперативных задач он не используется.

Цель — один Telegram-бот, в который летит любая задача голосом или текстом, и эта задача оказывается в нужном месте Obsidian-вальта без участия человека в сортировке.

---

## Архитектура

```
Telegram (личный бот)
        │
        ▼
   Raspberry Pi  ◄────┐
   ─────────────       │
   - bot daemon        │
   - SQLite БД         │
   - OpenRouter API    │
        │              │
        │ push при     │
        │ изменении    │
        ▼              │
   Приватный GitHub-репо
        ▲
        │ pull раз в 5 мин
        │
   Mac (launchd-сервис)
        │
        │ записывает в файлы
        ▼
   iCloud-вальт ──► iPhone (Obsidian Mobile)
```

GitHub-репо одновременно выполняет две функции: мост между Pi и Маком, и бэкап БД на случай смерти SD-карты.

---

## Компоненты

### 1. Telegram-бот (личный)

Регистрируется через BotFather, привязан к её аккаунту. Принимает только сообщения от неё.

Принимает текст. На голосовое отвечает «голос пока не умею, напиши текстом».

### 2. Daemon на Raspberry Pi

Постоянно запущенный Python-процесс рядом с Conference Bot.

**Обязанности:**
- Поддерживать соединение с Telegram (long polling)
- На входящее сообщение — отправлять текст в OpenRouter для классификации и извлечения дедлайна
- Сохранять задачу в локальную SQLite-БД со статусом `pending_confirmation`
- Отвечать пользователю кнопками подтверждения
- На тап кнопки — менять статус задачи на `confirmed`, пушить изменение в GitHub-репо
- В 9:00 (Asia/Yekaterinburg) — формировать утреннюю сводку, сохранять маппинг `номер → id` в `last_summary[morning]`, отправлять в чат
- В 21:00 — формировать вечернюю сводку (что закрыла, что осталось из «сегодня»), обновлять `last_summary[evening]`
- В воскресенье в 18:00 — формировать недельный обзор беклога, обновлять `last_summary[weekly]`
- Принимать команду `/today` — вручную пересчитать сводку «на сейчас», обновить `last_summary[manual]`. Используется если хочется свежую нумерацию, или утренней сводки ещё не было
- Принимать команды `/done N`, `/done N,M` — резолвить номера через **самый свежий** из `last_summary` (по `created_at`). Если ни одной сводки не было — отвечать «вызови /today чтобы получить нумерацию»
- Принимать команду `/del N` — резолвить так же, через **самый свежий** `last_summary`. Удаление доступно всегда (не только в воскресном обзоре): мало ли запишешь задачу с опечаткой и захочешь убрать сразу. Если сводок не было — отвечать «вызови /today чтобы получить нумерацию»
- Поддерживать кнопку «✕ Удалить» в карточке задачи сразу после её записи и после подтверждения — чтобы убрать ошибочную задачу без вызова `/today`. Кнопка делает то же, что `/del`: переводит статус в `cancelled`
- Принимать команды `/this N`, `/skip N` — резолвить через `last_summary[weekly]`. Если воскресного обзора ещё не было — отвечать «эти команды доступны только во время воскресного обзора»

### 3. Классификатор (OpenRouter)

Модель: `meta-llama/llama-3.1-8b-instruct:free` (или другая бесплатная — выбрать в момент реализации).

**Вход:** текст задачи
**Выход:** JSON с полями `project`, `deadline_iso` (или null), `deadline_kind` (`date` | `week` | `none`)

`deadline_iso` — единственный источник правды для логики (фильтры «на сегодня», «просрочено», и т.д.). `deadline_kind` нужен только для отображения: `week` показывается как «на неделе», `date` — как конкретная дата, `none` — «без срока».

Промпт получает список проектов с примерами задач. Если уверенность низкая — возвращает `project: "_inbox"`.

### 4. БД на Pi (SQLite)

```sql
CREATE TABLE tasks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  text TEXT NOT NULL,
  project TEXT NOT NULL,
  deadline DATE NULL,                          -- источник правды для логики
  deadline_kind TEXT NOT NULL DEFAULT 'none',  -- 'date' | 'week' | 'none'
  status TEXT NOT NULL,                        -- 'pending_confirmation' | 'confirmed' | 'done' | 'cancelled'
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,                -- обновляется при любом изменении полей
  confirmed_at DATETIME NULL,
  done_at DATETIME NULL
);

-- Маппинг номеров последней сводки в id задач
-- Чтобы /done 1,2 не ломалось, если между сводками прилетели новые задачи
CREATE TABLE last_summary (
  summary_type TEXT PRIMARY KEY,    -- 'morning' | 'evening' | 'weekly' | 'manual'
  numbers_to_ids TEXT NOT NULL,     -- JSON: {"1": 47, "2": 53, ...}
  created_at DATETIME NOT NULL
);

-- Инвариант (контролируется в коде, не в SQL):
--   deadline_kind = 'none' ⇔ deadline IS NULL
--   deadline_kind ∈ ('date', 'week') ⇒ deadline IS NOT NULL
-- Любая запись/апдейт строит deadline и deadline_kind вместе, никогда раздельно.
```

### 5. GitHub-репо

Приватный репозиторий, например `tasks-bot-data`. Содержит:
- `tasks.json` — **главный артефакт синка**, текстовый снапшот всех задач. Меняется построчно, чистые diff'ы, читается Маком.
- `tasks.db` — бэкап SQLite целиком. Бинарный, меняется блоком, поэтому коммитится **реже** (раз в час) с типом `backup:`. Не нужен для синка, только для восстановления Pi.

**Формат `tasks.json`** — массив объектов, отсортированный по `id`:

```json
[
  {
    "id": 47,
    "text": "позвонить Ане",
    "project": "mars",
    "deadline": "2026-04-30",
    "deadline_kind": "date",
    "status": "confirmed",
    "created_at": "2026-04-29T10:15:00+05:00",
    "updated_at": "2026-04-29T10:15:30+05:00",
    "confirmed_at": "2026-04-29T10:15:30+05:00",
    "done_at": null
  },
  {
    "id": 48,
    "text": "идея для контент-плана",
    "project": "mars",
    "deadline": null,
    "deadline_kind": "none",
    "status": "confirmed",
    "created_at": "2026-04-29T11:02:00+05:00",
    "updated_at": "2026-04-29T11:02:15+05:00",
    "confirmed_at": "2026-04-29T11:02:15+05:00",
    "done_at": null
  }
]
```

В JSON **не попадают** задачи со статусом `pending_confirmation` — они живут только в SQLite на Pi до момента подтверждения.

**Стратегия синка на Pi (паттерн "dirty"):**

Не ведём очередь событий, не запоминаем «что именно изменилось». Используем простой dirty-флаг и фоновый воркер.

- При любом изменении задачи в SQLite: помечаем in-memory счётчик `dirty_generation += 1`. На диск ничего не пишем.
- Фоновый воркер каждые 5 секунд проверяет: если `dirty_generation > synced_generation` — берёт текущее значение `dirty_generation` в локальную переменную `pending`, **пересобирает `tasks.json` из SQLite целиком**, коммитит, пушит.
- Если push успешен → `synced_generation = pending`. Если упал — `synced_generation` не двигается, через 5 секунд попробуем снова.
- **На старте daemon ставит `dirty_generation = 1, synced_generation = 0`** и принудительно пересобирает + пушит `tasks.json`. Это гарантирует консистентность даже после нештатной перезагрузки или ручного редактирования БД.
- Раз в час отдельный воркер коммитит `tasks.db` как `backup: snapshot`.

> **Патч от 2026-05-02:** изначально в спеке стоял `dirty: bool`, но он давал гонку — новая запись в момент push потерялась бы (флаг сбросился сразу после коммита, а изменение пришло между «считали dirty» и «сбросили dirty»). Поэтому в Плане 1 паттерн эволюционировал в монотонный generation counter: `synced_generation` догоняет `dirty_generation`, никогда не превышает.

Сообщения коммитов сводятся к `change: snapshot` (или с кратким суммари последнего изменения, если хочется красоты в истории).

**Преимущества подхода:** нет очередей, нет «потерянных» событий, состояние всегда восстанавливается из SQLite — единственного источника правды.

**Offline-устойчивость.** Подтверждение и захват задачи **не блокируются** недоступностью GitHub. Поток такой:
1. Pi записывает задачу в SQLite — это всегда успех (локальный диск)
2. Pi отвечает пользователю в Telegram — задача сохранена
3. Dirty-флаг становится `True`
4. Воркер пробует push, при неудаче — снова через 5 секунд
5. Пока push не прошёл — Mac не получит задачу, но БД на Pi содержит истину

**Конфликты git.** В репозиторий пишет **только Pi**, Mac строго read-only. Конфликтов write-write быть не должно. Если по какой-то причине push отклонён — Pi делает `git fetch && git reset --hard origin/main`, затем заново пересобирает `tasks.json` из своей SQLite (источник правды) и пушит. Маку это не помеха.

### 6. Launchd-сервис на Маке

Маленький Python-скрипт, запускается раз в 5 минут через `launchd`. Однонаправленный поток: только читает у Pi, никогда не пишет назад.

**Локальный стейт-файл** `~/.tasks-bot-sync/state.json`:
```json
{
  "tasks": {
    "<task_id>": {
      "status": "...",
      "deadline": "...",
      "deadline_kind": "...",
      "project": "...",
      "text": "...",
      "done_at": "...",
      "file_path": "projects/mars/tasks.md",
      "in_file": true
    }
  }
}
```

Хранит **полный fingerprint** ключевых полей и флаг `in_file` — была ли задача уже записана в markdown.

**Fingerprint задачи** (для сравнения):
```
fingerprint = (status, text, project, deadline, deadline_kind, done_at)
```

Если хоть одно поле отличается от локального стейта — задача считается изменённой, строку в markdown надо обновить.

**Принцип:** не патчить куски строки (заменять `[ ]` на `[x]`, дописывать `✅`, etc.), а **всегда перерендеривать строку целиком** по текущим полям задачи. Это в разы проще и надёжнее, чем разные операции на разные виды изменений.

**Функция рендера:**
```
render(task) -> string:
    box = "[x]" if task.status == "done" else "[ ]"
    deadline_part = " 📅 " + task.deadline if task.deadline else ""
    done_part = " ✅ " + task.done_at[:10] if task.status == "done" else ""
    return f"- {box} {task.text}{deadline_part}{done_part} <!-- taskbot:{task.id} -->"
```

**Обязанности (алгоритм синка):**

```
git pull
для каждой задачи T в tasks.json:
    S = local_state.get(T.id)
    target_file = projects/{T.project}/tasks.md

    если T.status == "cancelled":
        если S и S.in_file: удалить строку по taskbot:T.id из S.file_path
        local_state[T.id] = snapshot(T, file_path=None, in_file=False)
        continue

    # Активная задача (confirmed или done)
    new_line = render(T)

    если S нет:
        дописать new_line в target_file
        local_state[T.id] = snapshot(T, file_path=target_file, in_file=True)

    иначе если fingerprint(T) == fingerprint(S):
        ничего не делать

    иначе если S.in_file == False:
        # Раньше была cancelled, теперь активную восстановили — записать заново
        дописать new_line в target_file
        local_state[T.id] = snapshot(T, file_path=target_file, in_file=True)

    иначе если T.project != S.project:
        # Запрещено в MVP, но защищаемся
        удалить строку taskbot:T.id из S.file_path
        дописать new_line в target_file
        local_state[T.id] = snapshot(T, file_path=target_file, in_file=True)

    иначе:
        # Изменились status/text/deadline — переписать строку
        заменить строку по taskbot:T.id в S.file_path на new_line
        local_state[T.id] = snapshot(T, file_path=S.file_path, in_file=True)

записать local_state на диск
```

Идемпотентно: повторный запуск с тем же `tasks.json` не делает изменений.

---

## Структура файлов в вальте

Файлы создаются скриптом, если их нет:

```
projects/mars/tasks.md
projects/channel/tasks.md
projects/consulting/tasks.md
projects/life/tasks.md
_inbox/tasks.md
```

Формат строки:
```markdown
- [ ] позвонить Ане 📅 2026-04-30 <!-- taskbot:47 -->
- [ ] идея для контент-плана <!-- taskbot:48 -->
- [x] купить корм коту 📅 2026-04-29 ✅ 2026-04-29 <!-- taskbot:46 -->
```

**Стабильный ID в HTML-комментарии** — это критично. Мак ищет задачу для апдейта именно по `taskbot:N`, не по тексту. Это спасает от:
- одинаковых текстов задач у разных id
- ручного редактирования текста задачи в Obsidian (текст не сломает синк)

В Obsidian комментарий не виден в режиме просмотра. Эмодзи-формат с датами совместим с плагином [Obsidian Tasks](https://publish.obsidian.md/tasks/), если поставишь.

---

## UX

### Сценарий 1 — захват с явным дедлайном

```
Ты:   позвонить Ане завтра насчёт концерта
Бот:  📝 Записал в Марс
      📅 завтра (30 апреля)

      Всё верно?
      [✓ Подтвердить]  [Сменить проект]  [Сменить срок]

Ты:   тап ✓
Бот:  ✅ Добавлено
```

### Сценарий 2 — без дедлайна в тексте

```
Ты:   идея для контент-плана
Бот:  📝 Записал в Марс
      📅 ?

      Когда?
      [Сегодня]  [Завтра]  [На неделе]  [Без срока]
      [Сменить проект]

Ты:   тап «На неделе»
Бот:  ✅ Добавлено
```

### Сценарий 3 — смена проекта

```
Бот:  📝 Записал в Марс
      ...
Ты:   тап «Сменить проект»
Бот:  В какой?
      [Марс]  [Канал]  [Консалтинг]  [Жизнь]  [Входящие]
Ты:   тап «Жизнь»
Бот:  ✅ Записал в Жизнь
```

### Сценарий 4 — утренняя сводка

```
Бот:  Доброе утро ☀️

      📚 Марс
        🔥 1. Позвонить Ане
        📅 3. Идея для контент-плана

      💼 Консалтинг
        📅 4. Подготовиться к консультации

      ❤️ Жизнь
        🔥 2. Купить корм коту
        💭 5. Прочитать книгу про маркетинг

      Закрыть: /done 1 (или /done 1,3,5)
```

**Формат сводок (общий):**
- Задачи группируются по проектам, проекты идут в фиксированном порядке: Марс → Канал → Консалтинг → Жизнь → Входящие
- Эмодзи проектов: `📚 Марс`, `📺 Канал`, `💼 Консалтинг`, `❤️ Жизнь`, `📥 Входящие`
- Внутри проекта: статус-эмодзи слева от номера задачи: `🔥` сегодня, `📅` на этой неделе, `💭` без срока, `🔴` просрочено
- Нумерация сквозная по всей сводке (сохраняется маппинг `номер → id` для `/done`)

### Сценарий 5 — закрытие задач

```
Ты:   /done 1,2
Бот:  ✅ Закрыто 2 задачи:
      — Позвонить Ане
      — Купить корм коту
```

### Сценарий 6 — вечерняя сводка (21:00)

```
Бот:  🌙 Итог дня

      Закрыла сегодня (2):
      — Позвонить Ане — Марс
      — Купить корм коту — Жизнь

      Осталось на сегодня (1):
      💼 Консалтинг
        1. Подготовиться к консультации

      [Перенести всё]
```

«Закрыла сегодня» — линейный список (не actionable, не нужна группировка). «Осталось» — группировка по проектам, статус-эмодзи опускается, потому что все задачи имеют срок «сегодня».

«Перенести» меняет дедлайн с «сегодня» на «завтра». «Оставить» — задача остаётся с просроченным сроком, всплывёт в воскресном обзоре.

### Сценарий 7 — недельный обзор беклога (воскресенье 18:00)

```
Бот:  📋 Воскресный обзор

      📚 Марс (3)
        🔴 1. Идея для контент-плана
        💭 3. Подготовить выступление
        💭 4. Пересмотреть бюджет на рекламу

      💼 Консалтинг (1)
        💭 5. Написать клиенту Васе

      ❤️ Жизнь (3)
        🔴 2. Прочитать книгу про маркетинг
        💭 6. Купить новые наушники
        💭 7. Записаться к врачу

      Команды:
      /this N   — поставить на эту неделю
      /skip N   — оставить без срока
      /del N    — удалить
```

В заголовке проекта в скобках — количество задач в этом проекте (для воскресного обзора — чтобы видеть, где скопилось).

Цель — раз в неделю не дать беклогу превратиться в свалку. Можно ничего не делать — обзор просто пройдёт.

---

## Промпт для классификации

```
Ты помогаешь Наташе разложить её задачи по проектам.

Проекты:
- mars — Школа музыки Марс (ученики, педагоги, концерты, расписание, набор, реклама школы)
- channel — Telegram-канал «Начальник тоже человек» (посты, идеи, темы)
- consulting — Консалтинг 1-1 (клиенты, подготовка к консультациям, разборы)
- life — Личное (быт, здоровье, кот, родственники, покупки)
- _inbox — если непонятно куда

Извлеки из задачи: проект, дедлайн (если упомянут).

Дедлайн всегда — конкретная ISO-дата (YYYY-MM-DD) или null.
- «сегодня» → дата {today_date}, kind=date
- «завтра» → дата {today_date+1}, kind=date
- день недели или конкретная дата → kind=date
- «на этой неделе», «к концу недели» → ближайшее воскресенье **включая сегодня** (если сегодня воскресенье — будет сегодня), kind=week
- ничего про срок не сказано → null, kind=none

Сегодня: {today_date}.

Верни JSON:
{
  "project": "mars" | "channel" | "consulting" | "life" | "_inbox",
  "deadline_iso": "YYYY-MM-DD" | null,
  "deadline_kind": "date" | "week" | "none"
}

Задача: {user_input}
```

---

## Что в MVP (v1)

- Личный Telegram-бот, текст
- Классификация через OpenRouter (бесплатная модель)
- Извлечение дедлайна из текста + кнопки выбора срока
- Кнопки подтверждения, смены проекта, смены срока (только до подтверждения — см. ниже)
- БД на Pi + автопуш в GitHub после каждого изменения
- Launchd-сервис на Маке, дописывает в `tasks.md` файлы
- Утренняя сводка в 9:00 (Asia/Yekaterinburg)
- Вечерняя сводка в 21:00: что закрыла, что осталось, опция перенести на завтра
- Воскресный обзор беклога в 18:00: просроченные и без срока с командами `/this`, `/skip`, `/del`
  - **Подстраховка:** если при реализации команды управления окажутся сложнее ожидаемого — откатываемся к read-only-обзору (просто список без команд). Чистый просмотр — уже половина пользы; команды добавим в фазу 2
- Команды `/done N`, `/today`, `/overdue`, `/del N`
- Кнопка «✕ Удалить» в карточке свежей задачи (на этапе подтверждения и сразу после) — чтобы убрать опечатку без `/today`
- Никакого автоподтверждения — задача без тапа кнопки висит в `pending_confirmation`

### Правила MVP по статусам и переходам

- **Смена проекта** разрешена **только до подтверждения** (через кнопку в карточке после классификации). После подтверждения — нельзя в MVP, надо удалить (`/del`) и пересоздать
- **`/del`** переводит задачу в статус `cancelled`, физически из БД не удаляет (история сохраняется); из markdown-файла строка удаляется целиком
- **Смена дедлайна** разрешена в любой момент через вечерний перенос или явные команды (в фазу 2), Mac-синк это подхватит благодаря fingerprint-сравнению

## Не в MVP (фаза 2)

- Голосовой ввод (Whisper)
- Двусторонняя синхронизация: ставишь `[x]` в Obsidian — Pi подхватывает
- Редактирование задач из бота (`/edit`)
- Уведомления о приближающихся дедлайнах
- Перенос задачи в другой проект уже после подтверждения

---

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

1. **Имя GitHub-репо и доступ.** Использовать существующий аккаунт. Решить в момент реализации.
2. **Конкретная бесплатная модель OpenRouter.** Список меняется, выбрать актуальную перед стартом.
3. **Что делать с задачами, которые висят в `pending_confirmation` несколько дней.** Пока — ничего, висят. Если станет проблемой — добавить ежедневное напоминание «у тебя 3 неподтверждённые задачи».
4. **Ручные правки в `tasks.md`.** Если ты допишешь задачу в файл руками — Pi об этом не узнает, в утренней сводке её не будет. В MVP это так и есть. В фазу 2 решит двусторонняя синхронизация.

---

## Риски

- **OpenRouter может убрать бесплатные модели.** Митигация: код абстрагирует провайдера, можно перейти на платную модель за копейки или на Whisper-локально.
- **Классификация будет ошибаться.** Митигация: всегда показываем результат с возможностью исправить, никакого автоподтверждения.
- **GitHub-репо как мост = задержка ~5 минут до появления задачи в вальте.** Митигация: для пользователя «правда» = БД на Pi, утренняя сводка идёт оттуда. Файлы в вальте нужны только для просмотра в Obsidian — там 5 минут не критично.
- **SD-карта Pi может умереть.** Митигация: каждое изменение коммитится в GitHub, восстановиться можно за 10 минут на новом Pi.
