---
project: vibe-coding
type: plan
date: 2026-04-30
status: ready-for-implementation
spec: "{vibe-coding} {plan} tasks-bot obsidian – 2026-04-29.md"
plan_part: "1 of 3"
---

# Tasks Bot — Plan 1: Pi Core + tasks.json Contract

> **Для исполнителей:** если у тебя есть скилы `superpowers:subagent-driven-development` или `superpowers:executing-plans` — используй их для пошагового выполнения. Если нет — просто иди по чек-боксам сверху вниз, после каждой задачи коммить.

**Goal:** Развернуть на Raspberry Pi Telegram-бот, который принимает текстовую задачу, классифицирует её через OpenRouter, ждёт подтверждения кнопками, сохраняет в SQLite и публикует обновлённый `tasks.json` в приватный GitHub-репозиторий через паттерн dirty-flag.

**Architecture:** Async Python-сервис. Слои строятся снизу вверх: модель задачи → SQLite → render → tasks.json → git push → CLI dev harness → классификатор → Telegram. После каждого слоя — рабочая, тестируемая программа.

**Tech Stack:** Python 3.11+, python-telegram-bot v21+, aiosqlite, httpx, pydantic v2, pytest, ruff. Запуск под systemd на Raspberry Pi.

---

## Главный интеграционный контракт

**Контракт между Планом 1 и Планом 2 — это `tasks.json` + правила рендера markdown-строки. Не внутренняя архитектура бота.**

Это значит:
- `tasks.json` имеет точно описанную форму (см. спек §«GitHub-репо»). Любые внутренние изменения Плана 1 не должны менять JSON
- Функция `render(task) -> str` определена в общем модуле `render.py` и используется одинаково на Pi и Маке. Если поведение рендера меняется — это breaking change для контракта
- Mac-сервис из Плана 2 может тестироваться на fixture-файлах `tasks.json` без живого Pi
- Pi-сервис из Плана 1 может тестироваться без Маков и Obsidian — достаточно проверить, что `tasks.json` корректен

Контракт фиксирован в коде через тесты (`test_tasks_json.py`, `test_render.py`).

---

## Структура проекта

Код живёт в `projects/vibe-coding/tasks-bot/`. План 1 создаёт следующие файлы:

```
projects/vibe-coding/tasks-bot/
├── pyproject.toml                # зависимости и конфиг
├── .env.example                  # шаблон env-переменных
├── .gitignore                    # игнор venv, .env, db, __pycache__
├── README.md                     # как запускать локально и на Pi
├── deploy/
│   └── tasks-bot.service         # systemd unit
├── src/
│   └── tasks_bot/
│       ├── __init__.py
│       ├── config.py             # загрузка env, проекты, расписания
│       ├── models.py             # Task, TaskStatus, Project, DeadlineKind
│       ├── db.py                 # async SQLite-операции
│       ├── render.py             # markdown line renderer (shared с Planом 2)
│       ├── tasks_json.py         # сериализация SQLite -> tasks.json
│       ├── classifier.py         # OpenRouter client
│       ├── git_sync.py           # dirty-flag worker + git push
│       ├── bot.py                # Telegram bot, кнопки подтверждения
│       ├── cli.py                # dev harness
│       └── main.py               # entry point: bot + sync workers
└── tests/
    ├── conftest.py               # фикстуры (in-memory db, tmp git-repo)
    ├── test_models.py
    ├── test_db.py
    ├── test_render.py
    ├── test_tasks_json.py
    ├── test_classifier.py
    ├── test_git_sync.py
    └── test_bot.py
```

---

## Фазы

- **Фаза A (4 задачи):** Скелет проекта
- **Фаза B (5 задач):** Модели и SQLite
- **Фаза C (4 задачи):** Render + tasks.json контракт
- **Фаза D (4 задачи):** Git sync worker
- **Фаза E (3 задачи):** CLI dev harness — **первая deployable точка**
- **Фаза F (3 задачи):** OpenRouter классификатор
- **Фаза G (5 задач):** Telegram-бот: захват и подтверждение
- **Фаза H (2 задачи):** Деплой на Pi через systemd — **финальная deployable точка**

После Фазы E можно запустить локально и через CLI добавить задачу, увидеть её в `tasks.json` в репозитории. После Фазы H — то же, но через Telegram-бот на Pi.

---

# Фаза A — Скелет проекта

## Задача A.1: Создать структуру каталогов и pyproject.toml

**Files:**
- Create: `projects/vibe-coding/tasks-bot/pyproject.toml`
- Create: `projects/vibe-coding/tasks-bot/.gitignore`
- Create: `projects/vibe-coding/tasks-bot/.env.example`

**Соглашение по рабочей директории:** все команды плана выполняются из корня репозитория (`/Users/nataliadudina/Desktop/ObsidianVault`), кроме команд внутри проекта (`pytest`, запуск бота и т.п.) — для них явно указано `cd projects/vibe-coding/tasks-bot`. Это убирает двусмысленность путей в git-командах.

- [ ] **Шаг 1: Создать каталоги (из корня репо)**

```bash
mkdir -p projects/vibe-coding/tasks-bot/src/tasks_bot \
          projects/vibe-coding/tasks-bot/tests \
          projects/vibe-coding/tasks-bot/deploy
touch projects/vibe-coding/tasks-bot/src/tasks_bot/__init__.py
touch projects/vibe-coding/tasks-bot/tests/__init__.py
```

- [ ] **Шаг 2: Записать pyproject.toml**

```toml
[project]
name = "tasks-bot"
version = "0.1.0"
description = "Telegram bot capturing tasks into Obsidian via classification"
requires-python = ">=3.11"
dependencies = [
    "python-telegram-bot>=21.0",
    "aiosqlite>=0.19",
    "httpx>=0.27",
    "pydantic>=2.0",
    "pydantic-settings>=2.0",
    "python-dotenv>=1.0",
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.5", "freezegun>=1.4"]

[project.scripts]
tasks-bot = "tasks_bot.main:run"
tasks-cli = "tasks_bot.cli:run"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py311"
```

- [ ] **Шаг 3: Записать .gitignore и .env.example**

`.gitignore`:
```
__pycache__/
*.pyc
.venv/
venv/
.env
*.db
.pytest_cache/
.ruff_cache/
dist/
build/
*.egg-info/
data-repo/
```

`.env.example`:
```
TELEGRAM_BOT_TOKEN=
TELEGRAM_OWNER_USER_ID=
OPENROUTER_API_KEY=
OPENROUTER_MODEL=meta-llama/llama-3.1-8b-instruct:free
DATA_REPO_URL=git@github.com:user/tasks-bot-data.git
DATA_REPO_PATH=/home/pi/tasks-bot/data-repo
DB_PATH=/home/pi/tasks-bot/tasks.db
TIMEZONE=Asia/Yekaterinburg
```

- [ ] **Шаг 4: Установить зависимости и убедиться что проект импортируется**

```bash
# заходим в проект для venv и установки
cd projects/vibe-coding/tasks-bot
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
python -c "import tasks_bot"
cd -  # возвращаемся в корень репо
```

Ожидание: команды отрабатывают без ошибок.

- [ ] **Шаг 5: Коммит (из корня репо)**

```bash
# проверка на всякий случай — должны быть в /Users/nataliadudina/Desktop/ObsidianVault
pwd

git add projects/vibe-coding/tasks-bot/pyproject.toml \
        projects/vibe-coding/tasks-bot/.gitignore \
        projects/vibe-coding/tasks-bot/.env.example \
        projects/vibe-coding/tasks-bot/src/tasks_bot/__init__.py \
        projects/vibe-coding/tasks-bot/tests/__init__.py
git commit -m "tasks-bot: scaffold project structure"
```

---

## Задача A.2: Конфиг через pydantic-settings

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/config.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_config.py`

- [ ] **Шаг 1: Написать падающий тест**

`tests/test_config.py`:
```python
import os
from tasks_bot.config import Settings, PROJECTS

def test_settings_loads_from_env(monkeypatch):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc")
    monkeypatch.setenv("TELEGRAM_OWNER_USER_ID", "123")
    monkeypatch.setenv("OPENROUTER_API_KEY", "key")
    monkeypatch.setenv("DATA_REPO_URL", "git@x:y.git")
    monkeypatch.setenv("DATA_REPO_PATH", "/tmp/r")
    monkeypatch.setenv("DB_PATH", "/tmp/t.db")

    s = Settings()
    assert s.telegram_bot_token == "abc"
    assert s.telegram_owner_user_id == 123
    assert s.timezone == "Asia/Yekaterinburg"  # default
    assert s.openrouter_model == "meta-llama/llama-3.1-8b-instruct:free"  # default

def test_projects_list():
    assert PROJECTS == ["mars", "channel", "consulting", "life", "_inbox"]
```

- [ ] **Шаг 2: Запустить тест, убедиться что падает**

```bash
pytest tests/test_config.py -v
```
Ожидание: FAIL — `tasks_bot.config` не существует.

- [ ] **Шаг 3: Реализовать config.py**

```python
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

PROJECTS = ["mars", "channel", "consulting", "life", "_inbox"]

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    telegram_bot_token: str
    telegram_owner_user_id: int
    openrouter_api_key: str
    openrouter_model: str = "meta-llama/llama-3.1-8b-instruct:free"
    data_repo_url: str
    data_repo_path: str
    db_path: str
    timezone: str = "Asia/Yekaterinburg"

@lru_cache
def get_settings() -> Settings:
    """Не делаем глобальный settings = Settings() — это сломает тесты,
    потому что выполнится на импорте до того, как тесты успеют
    подменить env-переменные через monkeypatch."""
    return Settings()
```

В тесте импорт делать ленивым внутри функции теста, либо использовать `Settings()` напрямую с monkeypatch на env. Везде в коде использовать `get_settings()`, не глобальную переменную.

- [ ] **Шаг 4: Запустить тест**

```bash
pytest tests/test_config.py -v
```
Ожидание: PASS.

- [ ] **Шаг 5: Коммит**

```bash
git add projects/vibe-coding/tasks-bot/src/tasks_bot/config.py \
        projects/vibe-coding/tasks-bot/tests/test_config.py
git commit -m "tasks-bot: add settings via pydantic-settings"
```

---

## Задача A.3: Timezone-aware clock helper

Везде в коде нужно единое время в часовом поясе из настроек. `datetime.now()` без TZ — мусор для ISO-сериализации (`tasks.json` ждёт `+05:00`).

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/clock.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_clock.py`

- [ ] **Шаг 1: Тест**

```python
from datetime import datetime, timezone, timedelta
from tasks_bot.clock import now_tz, today_tz

def test_now_tz_is_aware():
    n = now_tz("Asia/Yekaterinburg")
    assert n.tzinfo is not None
    assert n.utcoffset() == timedelta(hours=5)

def test_today_tz_is_date():
    d = today_tz("Asia/Yekaterinburg")
    assert d.year >= 2026
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать**

```python
from datetime import datetime, date
from zoneinfo import ZoneInfo

def now_tz(tz: str) -> datetime:
    return datetime.now(ZoneInfo(tz))

def today_tz(tz: str) -> date:
    return now_tz(tz).date()
```

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add timezone-aware clock helper"
```

**Правило для всех последующих задач:** в коде использовать `now_tz(get_settings().timezone)` вместо `datetime.now()`. В тестах фикстуры явно строят aware-datetime через `datetime(..., tzinfo=ZoneInfo("Asia/Yekaterinburg"))`. Это выпрямит ISO-сериализацию в `tasks.json`.

---

## Задача A.4: README с инструкцией запуска

**Files:**
- Create: `projects/vibe-coding/tasks-bot/README.md`

- [ ] **Шаг 1: Написать README**

```markdown
# tasks-bot

Telegram-бот для захвата задач с автоклассификацией по проектам.

## Локальная разработка

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
# заполнить .env
pytest
```

## CLI dev harness

```bash
tasks-cli add "позвонить Ане завтра"
tasks-cli list
tasks-cli confirm <id>
tasks-cli rebuild-json
```

## Запуск бота

```bash
tasks-bot
```

## Деплой на Pi

См. `deploy/tasks-bot.service` — systemd unit.
```

- [ ] **Шаг 2: Коммит**

```bash
git add projects/vibe-coding/tasks-bot/README.md
git commit -m "tasks-bot: add README"
```

---

# Фаза B — Модели и SQLite

## Задача B.1: Pydantic-модели и enum-ы

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/models.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_models.py`

- [ ] **Шаг 1: Падающий тест**

`tests/test_models.py`:
```python
from datetime import datetime, date
import pytest
from tasks_bot.models import Task, TaskStatus, DeadlineKind

def test_task_creation_minimal():
    t = Task(
        id=1, text="купить корм", project="life",
        deadline=None, deadline_kind=DeadlineKind.NONE,
        status=TaskStatus.PENDING_CONFIRMATION,
        created_at=datetime(2026, 4, 30), updated_at=datetime(2026, 4, 30),
    )
    assert t.deadline is None
    assert t.deadline_kind == DeadlineKind.NONE

def test_invariant_none_kind_requires_null_deadline():
    with pytest.raises(ValueError, match="deadline must be null"):
        Task(
            id=1, text="x", project="life",
            deadline=date(2026, 5, 1), deadline_kind=DeadlineKind.NONE,
            status=TaskStatus.CONFIRMED,
            created_at=datetime.now(), updated_at=datetime.now(),
        )

def test_invariant_date_kind_requires_deadline():
    with pytest.raises(ValueError, match="deadline must be set"):
        Task(
            id=1, text="x", project="life",
            deadline=None, deadline_kind=DeadlineKind.DATE,
            status=TaskStatus.CONFIRMED,
            created_at=datetime.now(), updated_at=datetime.now(),
        )

def test_fingerprint_changes_on_field_change():
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind=DeadlineKind.NONE,
        status=TaskStatus.CONFIRMED,
        created_at=datetime(2026, 4, 30), updated_at=datetime(2026, 4, 30),
    )
    t1 = Task(**base)
    t2 = Task(**{**base, "text": "y"})
    t3 = Task(**{**base, "deadline": date(2026, 5, 1), "deadline_kind": DeadlineKind.DATE})
    assert t1.fingerprint() != t2.fingerprint()
    assert t1.fingerprint() != t3.fingerprint()
```

- [ ] **Шаг 2: Запустить — упадёт**

```bash
pytest tests/test_models.py -v
```

- [ ] **Шаг 3: Реализовать models.py**

```python
from datetime import datetime, date
from enum import StrEnum
from typing import Optional
from pydantic import BaseModel, model_validator

class TaskStatus(StrEnum):
    PENDING_CONFIRMATION = "pending_confirmation"
    CONFIRMED = "confirmed"
    DONE = "done"
    CANCELLED = "cancelled"

class DeadlineKind(StrEnum):
    DATE = "date"
    WEEK = "week"
    NONE = "none"

class Task(BaseModel):
    id: int
    text: str
    project: str
    deadline: Optional[date]
    deadline_kind: DeadlineKind
    status: TaskStatus
    created_at: datetime
    updated_at: datetime
    confirmed_at: Optional[datetime] = None
    done_at: Optional[datetime] = None

    @model_validator(mode="after")
    def check_deadline_invariant(self):
        if self.deadline_kind == DeadlineKind.NONE and self.deadline is not None:
            raise ValueError("deadline must be null when deadline_kind=none")
        if self.deadline_kind in (DeadlineKind.DATE, DeadlineKind.WEEK) and self.deadline is None:
            raise ValueError(f"deadline must be set when deadline_kind={self.deadline_kind}")
        return self

    def fingerprint(self) -> tuple:
        return (
            self.status, self.text, self.project,
            self.deadline, self.deadline_kind, self.done_at,
        )
```

- [ ] **Шаг 4: Тесты зелёные**

```bash
pytest tests/test_models.py -v
```

- [ ] **Шаг 5: Коммит**

```bash
git add projects/vibe-coding/tasks-bot/src/tasks_bot/models.py \
        projects/vibe-coding/tasks-bot/tests/test_models.py
git commit -m "tasks-bot: add Task model with deadline invariant"
```

---

## Задача B.2: SQLite-схема через миграционный скрипт

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/db.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_db.py`
- Test: `projects/vibe-coding/tasks-bot/tests/conftest.py`

- [ ] **Шаг 1: Конфтест с in-memory БД**

`tests/conftest.py`:
```python
import pytest
import aiosqlite
from tasks_bot.db import Database

@pytest.fixture
async def db():
    d = Database(":memory:")
    await d.connect()
    await d.migrate()
    yield d
    await d.close()
```

- [ ] **Шаг 2: Падающий тест на миграцию**

`tests/test_db.py`:
```python
import pytest

async def test_migrate_creates_tables(db):
    rows = await db.fetchall(
        "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
    )
    names = [r[0] for r in rows]
    assert "tasks" in names
    assert "last_summary" in names

async def test_tasks_columns(db):
    cols = await db.fetchall("PRAGMA table_info(tasks)")
    names = {row[1] for row in cols}
    expected = {
        "id", "text", "project", "deadline", "deadline_kind",
        "status", "created_at", "updated_at", "confirmed_at", "done_at",
    }
    assert expected.issubset(names)

async def test_last_summary_columns(db):
    cols = await db.fetchall("PRAGMA table_info(last_summary)")
    names = {row[1] for row in cols}
    assert {"summary_type", "numbers_to_ids", "created_at"}.issubset(names)
```

- [ ] **Шаг 3: Запустить — упадёт**

- [ ] **Шаг 4: Реализовать db.py (часть 1: connect/migrate/fetchall)**

```python
import aiosqlite
from typing import Optional

SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    text TEXT NOT NULL,
    project TEXT NOT NULL,
    deadline DATE NULL,
    deadline_kind TEXT NOT NULL DEFAULT 'none',
    status TEXT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    confirmed_at DATETIME NULL,
    done_at DATETIME NULL
);

CREATE TABLE IF NOT EXISTS last_summary (
    summary_type TEXT PRIMARY KEY,
    numbers_to_ids TEXT NOT NULL,
    created_at DATETIME NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project);
"""

class Database:
    def __init__(self, path: str):
        self.path = path
        self.conn: Optional[aiosqlite.Connection] = None

    async def connect(self):
        self.conn = await aiosqlite.connect(self.path)
        self.conn.row_factory = aiosqlite.Row
        await self.conn.execute("PRAGMA foreign_keys = ON")

    async def close(self):
        if self.conn:
            await self.conn.close()

    async def migrate(self):
        for stmt in SCHEMA.strip().split(";"):
            if stmt.strip():
                await self.conn.execute(stmt)
        await self.conn.commit()

    async def fetchall(self, sql: str, params: tuple = ()):
        cur = await self.conn.execute(sql, params)
        return await cur.fetchall()

    async def fetchone(self, sql: str, params: tuple = ()):
        cur = await self.conn.execute(sql, params)
        return await cur.fetchone()

    async def execute(self, sql: str, params: tuple = ()) -> int:
        cur = await self.conn.execute(sql, params)
        await self.conn.commit()
        return cur.lastrowid
```

- [ ] **Шаг 5: Тесты зелёные**

- [ ] **Шаг 6: Коммит**

```bash
git commit -am "tasks-bot: add Database with schema migration"
```

---

## Задача B.3: CRUD-операции для задач (insert, update_status, fetch_active)

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/db.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_db.py`

- [ ] **Шаг 1: Падающие тесты на CRUD**

Добавить в `tests/test_db.py`:
```python
from datetime import datetime, date
from tasks_bot.models import Task, TaskStatus, DeadlineKind

async def test_insert_and_fetch_task(db):
    now = datetime(2026, 4, 30, 10, 0, 0)
    task_id = await db.insert_task(
        text="позвонить Ане",
        project="mars",
        deadline=date(2026, 5, 1),
        deadline_kind=DeadlineKind.DATE,
        status=TaskStatus.PENDING_CONFIRMATION,
        now=now,
    )
    t = await db.get_task(task_id)
    assert t.text == "позвонить Ане"
    assert t.project == "mars"
    assert t.status == TaskStatus.PENDING_CONFIRMATION

async def test_update_status_sets_timestamp(db):
    now = datetime(2026, 4, 30, 10, 0, 0)
    tid = await db.insert_task(
        text="x", project="life", deadline=None, deadline_kind=DeadlineKind.NONE,
        status=TaskStatus.PENDING_CONFIRMATION, now=now,
    )
    later = datetime(2026, 4, 30, 11, 0, 0)
    await db.update_status(tid, TaskStatus.CONFIRMED, now=later)
    t = await db.get_task(tid)
    assert t.status == TaskStatus.CONFIRMED
    assert t.confirmed_at == later
    assert t.updated_at == later

async def test_fetch_for_json_excludes_only_pending(db):
    """В tasks.json публикуем всё кроме pending_confirmation.
    cancelled включается, чтобы Mac знал, что строку надо удалить."""
    now = datetime(2026, 4, 30)
    a = await db.insert_task("a", "life", None, DeadlineKind.NONE,
                              TaskStatus.PENDING_CONFIRMATION, now)
    b = await db.insert_task("b", "life", None, DeadlineKind.NONE,
                              TaskStatus.CONFIRMED, now)
    c = await db.insert_task("c", "life", None, DeadlineKind.NONE,
                              TaskStatus.CANCELLED, now)
    d = await db.insert_task("d", "life", None, DeadlineKind.NONE,
                              TaskStatus.DONE, now)

    active = await db.fetch_for_json()
    ids = [t.id for t in active]
    assert b in ids and c in ids and d in ids
    assert a not in ids
```

- [ ] **Шаг 2: Запустить — упадёт**

- [ ] **Шаг 3: Реализовать CRUD в db.py**

Добавить в класс `Database`:
```python
from .models import Task, TaskStatus, DeadlineKind

def _row_to_task(row) -> Task:
    return Task(
        id=row["id"], text=row["text"], project=row["project"],
        deadline=row["deadline"], deadline_kind=DeadlineKind(row["deadline_kind"]),
        status=TaskStatus(row["status"]),
        created_at=row["created_at"], updated_at=row["updated_at"],
        confirmed_at=row["confirmed_at"], done_at=row["done_at"],
    )

# methods of Database:
async def insert_task(self, text, project, deadline, deadline_kind,
                      status, now) -> int:
    cur = await self.conn.execute(
        """INSERT INTO tasks (text, project, deadline, deadline_kind,
                              status, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?)""",
        (text, project, deadline.isoformat() if deadline else None,
         deadline_kind.value, status.value, now.isoformat(), now.isoformat()),
    )
    await self.conn.commit()
    return cur.lastrowid

async def get_task(self, task_id: int) -> Task:
    row = await self.fetchone("SELECT * FROM tasks WHERE id = ?", (task_id,))
    return _row_to_task(row)

async def update_status(self, task_id: int, status: TaskStatus, now):
    extra = ""
    if status == TaskStatus.CONFIRMED:
        extra = ", confirmed_at = ?"
        params = (status.value, now.isoformat(), now.isoformat(), task_id)
    elif status == TaskStatus.DONE:
        extra = ", done_at = ?"
        params = (status.value, now.isoformat(), now.isoformat(), task_id)
    else:
        params = (status.value, now.isoformat(), task_id)
    await self.conn.execute(
        f"UPDATE tasks SET status = ?, updated_at = ?{extra} WHERE id = ?",
        params,
    )
    await self.conn.commit()

async def update_deadline(self, task_id: int, deadline, deadline_kind, now):
    await self.conn.execute(
        """UPDATE tasks SET deadline = ?, deadline_kind = ?, updated_at = ?
           WHERE id = ?""",
        (deadline.isoformat() if deadline else None, deadline_kind.value,
         now.isoformat(), task_id),
    )
    await self.conn.commit()

async def update_project(self, task_id: int, project: str, now):
    await self.conn.execute(
        "UPDATE tasks SET project = ?, updated_at = ? WHERE id = ?",
        (project, now.isoformat(), task_id),
    )
    await self.conn.commit()

async def fetch_for_json(self) -> list[Task]:
    """Все задачи кроме pending_confirmation. cancelled тоже включаются —
    Mac должен увидеть отмену и удалить строку."""
    rows = await self.fetchall(
        "SELECT * FROM tasks WHERE status != ? ORDER BY id",
        (TaskStatus.PENDING_CONFIRMATION.value,),
    )
    return [_row_to_task(r) for r in rows]
```

- [ ] **Шаг 4: Тесты зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add task CRUD operations"
```

---

## Задача B.4: last_summary CRUD

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/db.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_db.py`

- [ ] **Шаг 1: Тесты**

Добавить:
```python
async def test_save_and_load_summary(db):
    now = datetime(2026, 4, 30, 9, 0)
    await db.save_summary("morning", {"1": 10, "2": 11}, now=now)
    mapping = await db.load_summary("morning")
    assert mapping == {"1": 10, "2": 11}

async def test_load_latest_summary_picks_most_recent(db):
    await db.save_summary("morning", {"1": 10}, now=datetime(2026, 4, 30, 9))
    await db.save_summary("evening", {"1": 11}, now=datetime(2026, 4, 30, 21))
    name, mapping = await db.load_latest_summary()
    assert name == "evening"
    assert mapping == {"1": 11}
```

- [ ] **Шаг 2: Запустить — упадёт**

- [ ] **Шаг 3: Реализовать**

```python
import json

async def save_summary(self, summary_type: str, numbers_to_ids: dict, now):
    await self.conn.execute(
        """INSERT INTO last_summary (summary_type, numbers_to_ids, created_at)
           VALUES (?, ?, ?)
           ON CONFLICT(summary_type) DO UPDATE SET
               numbers_to_ids = excluded.numbers_to_ids,
               created_at = excluded.created_at""",
        (summary_type, json.dumps(numbers_to_ids), now.isoformat()),
    )
    await self.conn.commit()

async def load_summary(self, summary_type: str) -> dict | None:
    row = await self.fetchone(
        "SELECT numbers_to_ids FROM last_summary WHERE summary_type = ?",
        (summary_type,),
    )
    if not row:
        return None
    return {k: int(v) for k, v in json.loads(row[0]).items()}

async def load_latest_summary(self) -> tuple[str, dict] | None:
    row = await self.fetchone(
        "SELECT summary_type, numbers_to_ids FROM last_summary "
        "ORDER BY created_at DESC LIMIT 1"
    )
    if not row:
        return None
    return row[0], {k: int(v) for k, v in json.loads(row[1]).items()}
```

- [ ] **Шаг 4: Тесты зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add last_summary persistence"
```

---

## Задача B.5: dirty generation counter (защита от гонки)

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/db.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_db.py`

**Почему не boolean.** Если использовать `dirty: bool`, есть классическая гонка:

```
1. worker видит dirty=True, начинает push (несколько секунд через asyncio.to_thread)
2. Telegram подтверждает новую задачу → ставит dirty=True
3. push завершается успешно → worker делает clear_dirty()
4. Сигнал о новой задаче из шага 2 потерян
```

Решение — счётчик поколений: запись инкрементирует `dirty_generation`, воркер запоминает «синкал поколение N», и пока `dirty_generation > synced_generation` — что-то ещё не запушено.

- [ ] **Шаг 1: Тесты**

```python
async def test_dirty_generation_increments_on_insert(db):
    assert db.is_dirty() is False
    await db.insert_task("x", "life", None, DeadlineKind.NONE,
                         TaskStatus.PENDING_CONFIRMATION, datetime.now())
    assert db.is_dirty() is True
    assert db.dirty_generation == 1

async def test_mark_synced_clears_dirty(db):
    await db.insert_task("x", "life", None, DeadlineKind.NONE,
                         TaskStatus.PENDING_CONFIRMATION, datetime.now())
    captured = db.dirty_generation
    db.mark_synced(captured)
    assert db.is_dirty() is False

async def test_race_safe_new_write_during_push(db):
    """Имитация гонки: воркер захватил generation, начал push.
    В это время прилетела новая запись. После завершения push мы
    зовём mark_synced(captured), но новая запись не теряется."""
    await db.insert_task("a", "life", None, DeadlineKind.NONE,
                         TaskStatus.PENDING_CONFIRMATION, datetime.now())
    captured = db.dirty_generation  # = 1
    # пока "пушим", прилетает новая запись
    await db.insert_task("b", "life", None, DeadlineKind.NONE,
                         TaskStatus.PENDING_CONFIRMATION, datetime.now())
    # = 2
    db.mark_synced(captured)  # ставит synced=1
    assert db.is_dirty() is True  # потому что dirty=2, synced=1
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

В `Database.__init__` добавить:
```python
self.dirty_generation = 0
self.synced_generation = 0
```

Метод-помощник, вызывается из всех write-методов (`insert_task`, `update_status`, `update_deadline`, `update_project`) после `commit`:
```python
def _mark_dirty(self):
    self.dirty_generation += 1
```

Публичные методы:
```python
def is_dirty(self) -> bool:
    return self.dirty_generation > self.synced_generation

def mark_synced(self, generation: int):
    """Идёт только вперёд: если за время push был ещё один write,
    его generation выше — не сбрасываем."""
    if generation > self.synced_generation:
        self.synced_generation = generation
```

- [ ] **Шаг 4: Тесты зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add dirty flag for git sync"
```

---

# Фаза C — Render + tasks.json контракт

## Задача C.1: render(task) функция

Эта функция — **общий артефакт** Плана 1 и Плана 2. План 2 импортирует её или повторяет один-в-один. Любые изменения здесь — breaking change для контракта.

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/render.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_render.py`

- [ ] **Шаг 1: Тесты**

`tests/test_render.py`:
```python
from datetime import datetime, date
from tasks_bot.models import Task, TaskStatus, DeadlineKind
from tasks_bot.render import render_line, parse_taskbot_id

def make_task(**kw):
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind=DeadlineKind.NONE,
        status=TaskStatus.CONFIRMED,
        created_at=datetime(2026, 4, 30), updated_at=datetime(2026, 4, 30),
    )
    base.update(kw)
    return Task(**base)

def test_render_minimal():
    t = make_task(id=42, text="купить корм")
    assert render_line(t) == "- [ ] купить корм <!-- taskbot:42 -->"

def test_render_with_deadline():
    t = make_task(id=42, text="позвонить Ане",
                   deadline=date(2026, 5, 1), deadline_kind=DeadlineKind.DATE)
    assert render_line(t) == "- [ ] позвонить Ане 📅 2026-05-01 <!-- taskbot:42 -->"

def test_render_done():
    t = make_task(id=42, text="купить корм",
                   deadline=date(2026, 4, 29), deadline_kind=DeadlineKind.DATE,
                   status=TaskStatus.DONE,
                   done_at=datetime(2026, 4, 29, 14, 30))
    assert render_line(t) == "- [x] купить корм 📅 2026-04-29 ✅ 2026-04-29 <!-- taskbot:42 -->"

def test_render_done_without_deadline():
    t = make_task(id=42, text="идея",
                   status=TaskStatus.DONE,
                   done_at=datetime(2026, 4, 29, 14, 30))
    assert render_line(t) == "- [x] идея ✅ 2026-04-29 <!-- taskbot:42 -->"

def test_parse_taskbot_id():
    line = "- [ ] купить корм 📅 2026-05-01 <!-- taskbot:42 -->"
    assert parse_taskbot_id(line) == 42

def test_parse_taskbot_id_none_for_unrelated_line():
    assert parse_taskbot_id("- [ ] обычная задача") is None
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
import re
from .models import Task, TaskStatus

ID_PATTERN = re.compile(r"<!--\s*taskbot:(\d+)\s*-->")

def render_line(task: Task) -> str:
    box = "[x]" if task.status == TaskStatus.DONE else "[ ]"
    parts = [f"- {box}", task.text]
    if task.deadline:
        parts.append(f"📅 {task.deadline.isoformat()}")
    if task.status == TaskStatus.DONE and task.done_at:
        parts.append(f"✅ {task.done_at.date().isoformat()}")
    parts.append(f"<!-- taskbot:{task.id} -->")
    return " ".join(parts)

def parse_taskbot_id(line: str) -> int | None:
    m = ID_PATTERN.search(line)
    return int(m.group(1)) if m else None
```

- [ ] **Шаг 4: Тесты зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add render_line and parse_taskbot_id (shared with Plan 2)"
```

---

## Задача C.2: tasks.json builder

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/tasks_json.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_tasks_json.py`

- [ ] **Шаг 1: Тесты**

`tests/test_tasks_json.py`:
```python
import json
from datetime import datetime, date
from tasks_bot.models import Task, TaskStatus, DeadlineKind
from tasks_bot.tasks_json import build_tasks_json

def make(**kw):
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind=DeadlineKind.NONE,
        status=TaskStatus.CONFIRMED,
        created_at=datetime(2026, 4, 30, 10, 0),
        updated_at=datetime(2026, 4, 30, 10, 0),
    )
    base.update(kw)
    return Task(**base)

def test_build_empty():
    assert build_tasks_json([]) == "[]"

def test_build_single():
    t = make(id=47, text="позвонить Ане", project="mars",
              deadline=date(2026, 4, 30), deadline_kind=DeadlineKind.DATE,
              confirmed_at=datetime(2026, 4, 29, 10, 15))
    out = json.loads(build_tasks_json([t]))
    assert len(out) == 1
    assert out[0]["id"] == 47
    assert out[0]["text"] == "позвонить Ане"
    assert out[0]["deadline"] == "2026-04-30"
    assert out[0]["deadline_kind"] == "date"
    assert out[0]["status"] == "confirmed"
    assert out[0]["done_at"] is None

def test_sorted_by_id():
    a = make(id=3, text="a")
    b = make(id=1, text="b")
    c = make(id=2, text="c")
    out = json.loads(build_tasks_json([a, b, c]))
    assert [t["id"] for t in out] == [1, 2, 3]

def test_iso_dates_formatted():
    t = make(id=1, deadline=date(2026, 5, 1), deadline_kind=DeadlineKind.DATE)
    out = json.loads(build_tasks_json([t]))
    assert out[0]["deadline"] == "2026-05-01"
    assert "T" in out[0]["created_at"]  # ISO 8601 datetime
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
import json
from .models import Task

def build_tasks_json(tasks: list[Task]) -> str:
    """Сериализовать список задач в pretty-JSON для git-коммита.
    Сортировка по id для стабильных diff'ов."""
    sorted_tasks = sorted(tasks, key=lambda t: t.id)
    payload = [_task_to_dict(t) for t in sorted_tasks]
    return json.dumps(payload, ensure_ascii=False, indent=2)

def _task_to_dict(t: Task) -> dict:
    return {
        "id": t.id,
        "text": t.text,
        "project": t.project,
        "deadline": t.deadline.isoformat() if t.deadline else None,
        "deadline_kind": t.deadline_kind.value,
        "status": t.status.value,
        "created_at": t.created_at.isoformat(),
        "updated_at": t.updated_at.isoformat(),
        "confirmed_at": t.confirmed_at.isoformat() if t.confirmed_at else None,
        "done_at": t.done_at.isoformat() if t.done_at else None,
    }
```

- [ ] **Шаг 4: Тесты зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add tasks.json builder"
```

---

## Задача C.3: тест полного round-trip DB → tasks.json

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/tests/test_tasks_json.py`

- [ ] **Шаг 1: Интеграционный тест**

```python
import pytest
from datetime import datetime, date
from tasks_bot.tasks_json import build_tasks_json
from tasks_bot.models import TaskStatus, DeadlineKind

async def test_db_to_tasks_json_excludes_pending(db):
    now = datetime(2026, 4, 30, 10, 0)
    await db.insert_task("a", "life", None, DeadlineKind.NONE,
                          TaskStatus.PENDING_CONFIRMATION, now)
    bid = await db.insert_task("b", "mars", date(2026, 5, 1), DeadlineKind.DATE,
                                 TaskStatus.CONFIRMED, now)

    tasks = await db.fetch_for_json()
    s = build_tasks_json(tasks)
    import json as j
    out = j.loads(s)
    assert len(out) == 1
    assert out[0]["id"] == bid
    assert out[0]["status"] == "confirmed"
```

- [ ] **Шаг 2: Запустить и убедиться что зелёный**

- [ ] **Шаг 3: Коммит**

```bash
git commit -am "tasks-bot: add round-trip test DB -> tasks.json"
```

---

## Задача C.4: фикстуры для Плана 2

Чтобы План 2 можно было разрабатывать и тестировать независимо — сохраняем «золотые» фикстуры `tasks.json` в репозитории, описывающие основные сценарии.

**Files:**
- Create: `projects/vibe-coding/tasks-bot/tests/fixtures/tasks_json/empty.json`
- Create: `projects/vibe-coding/tasks-bot/tests/fixtures/tasks_json/single_active.json`
- Create: `projects/vibe-coding/tasks-bot/tests/fixtures/tasks_json/with_done_and_cancelled.json`
- Create: `projects/vibe-coding/tasks-bot/tests/fixtures/tasks_json/multiple_projects.json`

- [ ] **Шаг 1: Сгенерировать фикстуры из тестов**

Написать тестовый скрипт `tests/generate_fixtures.py`, который собирает каждый JSON через `build_tasks_json` и пишет в файл. Запустить, закоммитить файлы.

- [ ] **Шаг 2: Тест что фикстуры валидны**

`tests/test_fixtures.py`:
```python
import json
from pathlib import Path

FIXTURE_DIR = Path(__file__).parent / "fixtures" / "tasks_json"

def test_fixtures_parse():
    for f in FIXTURE_DIR.glob("*.json"):
        json.loads(f.read_text())  # не падает
```

- [ ] **Шаг 3: Коммит**

```bash
git commit -am "tasks-bot: add tasks.json fixtures for Plan 2 isolation"
```

---

# Фаза D — Git sync worker

## Задача D.1: Газовая обёртка над git-командами

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/git_sync.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_git_sync.py`

- [ ] **Шаг 1: Тесты с настоящим временным репо**

```python
import pytest
from pathlib import Path
import subprocess
from tasks_bot.git_sync import GitRepo

@pytest.fixture
def tmp_repo(tmp_path):
    """Bare-репо для проверки push'ей.
    Явно используем 'main' и пушим в origin HEAD, потому что у разных
    версий git и пустых bare-репо default branch может отличаться."""
    bare = tmp_path / "remote.git"
    work = tmp_path / "work"
    subprocess.run(["git", "init", "--bare", "-b", "main", str(bare)], check=True)
    subprocess.run(["git", "clone", str(bare), str(work)], check=True)
    subprocess.run(["git", "-C", str(work), "checkout", "-b", "main"], check=False)
    subprocess.run(["git", "-C", str(work), "config", "user.email", "t@e.st"], check=True)
    subprocess.run(["git", "-C", str(work), "config", "user.name", "test"], check=True)
    (work / "README.md").write_text("init")
    subprocess.run(["git", "-C", str(work), "add", "."], check=True)
    subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
    subprocess.run(["git", "-C", str(work), "push", "-u", "origin", "HEAD:main"], check=True)
    return GitRepo(str(work))

def test_write_commit_push(tmp_repo, tmp_path):
    tmp_repo.write_file("tasks.json", "[]")
    tmp_repo.commit_and_push("change: snapshot")

    # новый клон должен видеть файл
    other = tmp_path / "other"
    subprocess.run(["git", "clone", str(tmp_repo.remote_url()), str(other)], check=True)
    assert (other / "tasks.json").read_text() == "[]"

def test_git_failure_returns_false(tmp_repo, monkeypatch):
    """Любая ошибка git-команды → commit_and_push возвращает False.

    NB: возвращать CompletedProcess(returncode=1) нельзя — исходный _run()
    использует check=True, при котором subprocess сам кидает CalledProcessError.
    Раньше тест возвращал stdout=None, и status.stdout.strip() падал с AttributeError
    вместо ожидаемого False."""
    def fail(*args, **kwargs):
        raise subprocess.CalledProcessError(1, list(args))
    monkeypatch.setattr(tmp_repo, "_run", fail)
    tmp_repo.write_file("tasks.json", "[]")
    assert tmp_repo.commit_and_push("x") is False
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
import subprocess
from pathlib import Path

class GitRepo:
    def __init__(self, path: str):
        self.path = Path(path)

    def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess:
        return subprocess.run(["git", "-C", str(self.path), *args],
                               capture_output=True, text=True, check=check)

    def write_file(self, name: str, content: str):
        (self.path / name).write_text(content)

    def commit_and_push(self, message: str) -> bool:
        try:
            self._run("add", ".")
            status = self._run("status", "--porcelain")
            if not status.stdout.strip():
                return True
            self._run("commit", "-m", message)
            self._run("push", "origin", "HEAD:main")
            return True
        except subprocess.CalledProcessError:
            return False

    def hard_reset_to_remote(self):
        self._run("fetch", "origin")
        self._run("reset", "--hard", "origin/HEAD")

    def remote_url(self) -> str:
        return self._run("remote", "get-url", "origin").stdout.strip()
```

- [ ] **Шаг 4: Зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add GitRepo wrapper"
```

---

## Задача D.2: Sync worker с dirty-flag

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/git_sync.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_git_sync.py`

- [ ] **Шаг 1: Тест**

```python
import asyncio
import json as j
from datetime import datetime
from tasks_bot.git_sync import SyncWorker
from tasks_bot.models import TaskStatus, DeadlineKind

async def test_worker_pushes_when_dirty(db, tmp_repo):
    worker = SyncWorker(db, tmp_repo, interval_seconds=0.1)

    await db.insert_task("x", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, datetime(2026, 4, 30, 10))

    task = asyncio.create_task(worker.run())
    await asyncio.sleep(0.3)
    worker.stop()
    await task

    content = (tmp_repo.path / "tasks.json").read_text()
    out = j.loads(content)
    assert len(out) == 1
    assert db.is_dirty() is False

async def test_worker_race_safe_during_push(db, tmp_repo, monkeypatch):
    """Если новая запись пришла во время push, её generation выше captured —
    после mark_synced(captured) воркер увидит is_dirty()=True и зайдёт ещё раз."""
    worker = SyncWorker(db, tmp_repo, interval_seconds=0.05)

    await db.insert_task("a", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, datetime(2026, 4, 30))

    # ВАЖНО: захватываем event loop ДО входа в to_thread.
    # Внутри thread его уже не получить через get_event_loop().
    loop = asyncio.get_running_loop()

    original = worker._do_sync
    def slow_sync(content):
        # имитируем медленный push: за это время прилетает новая задача
        async def insert_during_push():
            await db.insert_task("b", "life", None, DeadlineKind.NONE,
                                  TaskStatus.CONFIRMED, datetime(2026, 4, 30))
        future = asyncio.run_coroutine_threadsafe(insert_during_push(), loop)
        future.result(timeout=2)
        return original(content)
    monkeypatch.setattr(worker, "_do_sync", slow_sync)

    task = asyncio.create_task(worker.run())
    await asyncio.sleep(0.5)
    worker.stop()
    await task

    out = j.loads((tmp_repo.path / "tasks.json").read_text())
    assert len(out) == 2  # обе задачи в финальном tasks.json
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать SyncWorker**

```python
import asyncio
from .tasks_json import build_tasks_json

class SyncWorker:
    def __init__(self, db, repo: GitRepo, interval_seconds: float = 5.0):
        self.db = db
        self.repo = repo
        self.interval = interval_seconds
        self._stop = False

    async def run(self):
        # Старт всегда форсит push: бампаем generation, чтобы первый
        # цикл точно сработал
        self.db._mark_dirty()
        while not self._stop:
            if self.db.is_dirty():
                # Захватываем поколение ДО push. Если в течение push
                # прилетит новая запись, она инкрементирует dirty_generation
                # выше captured — после mark_synced(captured) is_dirty()
                # снова вернёт True и мы зайдём на новый круг.
                captured = self.db.dirty_generation
                ok = await self._sync_once()
                if ok:
                    self.db.mark_synced(captured)
            await asyncio.sleep(self.interval)

    async def _sync_once(self) -> bool:
        tasks = await self.db.fetch_for_json()
        content = build_tasks_json(tasks)
        # git синхронный и может зависать секундами при медленной сети.
        # Выносим в thread pool, чтобы Telegram event loop не вис.
        return await asyncio.to_thread(self._do_sync, content)

    def _do_sync(self, content: str) -> bool:
        self.repo.write_file("tasks.json", content)
        return self.repo.commit_and_push_with_recovery("change: snapshot")

    def stop(self):
        self._stop = True
```

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add SyncWorker with dirty-flag pattern"
```

---

## Задача D.3: Backup worker для tasks.db

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/git_sync.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_git_sync.py`

- [ ] **Шаг 1: Тест**

```python
async def test_backup_worker_copies_db(db, tmp_repo, tmp_path):
    db_path = tmp_path / "tasks.db"
    # заполняем БД через путь к настоящему файлу
    await db.close()
    real_db = Database(str(db_path))
    await real_db.connect()
    await real_db.migrate()
    await real_db.insert_task("x", "life", None, DeadlineKind.NONE,
                                TaskStatus.CONFIRMED, datetime(2026, 4, 30))

    worker = BackupWorker(str(db_path), tmp_repo, interval_seconds=0.1)
    task = asyncio.create_task(worker.run())
    await asyncio.sleep(0.3)
    worker.stop()
    await task

    assert (tmp_repo.path / "tasks.db").exists()
    assert (tmp_repo.path / "tasks.db").stat().st_size > 0
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать BackupWorker**

Копировать живую SQLite-БД через `shutil.copyfile` опасно — можно получить полусогласованный файл. Используем `VACUUM INTO`, который атомарно создаёт согласованную копию средствами самой SQLite.

```python
import sqlite3
import asyncio
from pathlib import Path

class BackupWorker:
    def __init__(self, db_path: str, repo: GitRepo, interval_seconds: float = 3600):
        self.db_path = db_path
        self.repo = repo
        self.interval = interval_seconds
        self._stop = False

    async def run(self):
        while not self._stop:
            await self._backup_once()
            await asyncio.sleep(self.interval)

    async def _backup_once(self):
        target = self.repo.path / "tasks.db"
        # VACUUM INTO требует, чтобы файл назначения отсутствовал
        if target.exists():
            target.unlink()
        # БД может быть открыта другим коннектом (daemon) — VACUUM INTO это переживает
        await asyncio.to_thread(self._do_vacuum, target)
        await asyncio.to_thread(self.repo.commit_and_push, "backup: snapshot")

    def _do_vacuum(self, target: Path):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(f"VACUUM INTO ?", (str(target),))

    def stop(self):
        self._stop = True
```

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add BackupWorker for tasks.db"
```

---

## Задача D.4: Recovery on push reject

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/git_sync.py`

- [ ] **Шаг 1: Тест на rejection**

```python
def test_repo_recovers_from_diverged_remote(tmp_repo, tmp_path):
    # делаем расхождение: пишем в remote напрямую
    other = tmp_path / "other"
    subprocess.run(["git", "clone", tmp_repo.remote_url(), str(other)], check=True)
    (other / "tasks.json").write_text('[{"id":99,"text":"foreign"}]')
    subprocess.run(["git", "-C", str(other), "add", "."], check=True)
    subprocess.run(["git", "-C", str(other), "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-m", "x"], check=True)
    subprocess.run(["git", "-C", str(other), "push"], check=True)

    # локальный push должен упасть и восстановиться
    tmp_repo.write_file("tasks.json", "[]")
    pushed = tmp_repo.commit_and_push_with_recovery("change: ours")
    assert pushed is True
    # после recovery в local должен быть наш файл
    assert (tmp_repo.path / "tasks.json").read_text() == "[]"
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать recovery в GitRepo**

```python
def commit_and_push_with_recovery(self, message: str) -> bool:
    if self.commit_and_push(message):
        return True
    # push упал — переписываем локальные изменения поверх remote
    pending_content = (self.path / "tasks.json").read_text() \
        if (self.path / "tasks.json").exists() else None
    self.hard_reset_to_remote()
    if pending_content is not None:
        self.write_file("tasks.json", pending_content)
    return self.commit_and_push(message)
```

В `SyncWorker._sync_once` использовать `commit_and_push_with_recovery`.

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: recover from diverged remote on push failure"
```

---

# Фаза E — CLI dev harness — **deployable точка #1**

## Задача E.1: CLI add

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/cli.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_cli.py`

- [ ] **Шаг 1: Тест**

```python
import asyncio
from tasks_bot.cli import cmd_add
from tasks_bot.models import TaskStatus

async def test_cli_add_creates_pending(db, capsys):
    await cmd_add(db, text="купить корм", project="life", deadline_arg=None)
    captured = capsys.readouterr()
    assert "id=" in captured.out

    rows = await db.fetchall("SELECT * FROM tasks")
    assert len(rows) == 1
    assert rows[0]["status"] == TaskStatus.PENDING_CONFIRMATION.value

async def test_cli_add_with_deadline_string(db, capsys):
    await cmd_add(db, text="x", project="life", deadline_arg="2026-05-01")
    rows = await db.fetchall("SELECT * FROM tasks")
    assert rows[0]["deadline"] == "2026-05-01"
    assert rows[0]["deadline_kind"] == "date"
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать CLI add**

```python
import argparse
import asyncio
from datetime import date
from .config import get_settings, PROJECTS
from .clock import now_tz
from .db import Database
from .models import TaskStatus, DeadlineKind

async def cmd_add(db: Database, text: str, project: str,
                   deadline_arg: str | None):
    deadline = None
    kind = DeadlineKind.NONE
    if deadline_arg:
        deadline = date.fromisoformat(deadline_arg)
        kind = DeadlineKind.DATE
    tid = await db.insert_task(
        text=text, project=project, deadline=deadline, deadline_kind=kind,
        status=TaskStatus.PENDING_CONFIRMATION,
        now=now_tz(get_settings().timezone),
    )
    print(f"id={tid} status=pending_confirmation")

def run():
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="cmd", required=True)

    a = sub.add_parser("add")
    a.add_argument("text")
    a.add_argument("--project", choices=PROJECTS, default="_inbox")
    a.add_argument("--deadline")

    args = p.parse_args()
    asyncio.run(_dispatch(args))

async def _dispatch(args):
    settings = get_settings()
    db = Database(settings.db_path)
    await db.connect()
    await db.migrate()
    if args.cmd == "add":
        await cmd_add(db, args.text, args.project, args.deadline)
    await db.close()
```

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add CLI 'add' command"
```

---

## Задача E.2: CLI list, confirm, cancel

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/cli.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_cli.py`

- [ ] **Шаг 1: Тесты**

```python
async def test_cli_list_shows_all_with_status(db, capsys):
    await db.insert_task("a", "life", None, DeadlineKind.NONE,
                          TaskStatus.PENDING_CONFIRMATION, datetime(2026, 4, 30))
    await db.insert_task("b", "mars", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, datetime(2026, 4, 30))
    await cmd_list(db)
    out = capsys.readouterr().out
    assert "a" in out and "pending" in out
    assert "b" in out and "confirmed" in out

async def test_cli_confirm_changes_status(db):
    tid = await db.insert_task("x", "life", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime(2026, 4, 30))
    await cmd_confirm(db, tid)
    t = await db.get_task(tid)
    assert t.status == TaskStatus.CONFIRMED

async def test_cli_cancel_changes_status(db):
    tid = await db.insert_task("x", "life", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, datetime(2026, 4, 30))
    await cmd_cancel(db, tid)
    t = await db.get_task(tid)
    assert t.status == TaskStatus.CANCELLED
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
async def cmd_list(db: Database):
    rows = await db.fetchall("SELECT id, text, project, status FROM tasks ORDER BY id")
    for r in rows:
        print(f"#{r['id']} [{r['status']}] {r['project']}: {r['text']}")

async def cmd_confirm(db: Database, task_id: int):
    await db.update_status(task_id, TaskStatus.CONFIRMED,
                            now=now_tz(get_settings().timezone))
    print(f"#{task_id} confirmed")

async def cmd_cancel(db: Database, task_id: int):
    await db.update_status(task_id, TaskStatus.CANCELLED,
                            now=now_tz(get_settings().timezone))
    print(f"#{task_id} cancelled")
```

В парсере добавить субкоманды `list`, `confirm <id>`, `cancel <id>`.

- [ ] **Шаг 4: Зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add CLI list/confirm/cancel"
```

---

## Задача E.3: CLI rebuild-json (ручной триггер)

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/cli.py`

- [ ] **Шаг 1: Тест**

```python
async def test_cli_rebuild_json_writes_file(db, tmp_repo):
    await db.insert_task("x", "life", None, DeadlineKind.NONE,
                          TaskStatus.CONFIRMED, datetime(2026, 4, 30))
    from tasks_bot.git_sync import GitRepo
    repo = GitRepo(str(tmp_repo.path))
    await cmd_rebuild_json(db, repo)
    content = (tmp_repo.path / "tasks.json").read_text()
    import json as j
    assert len(j.loads(content)) == 1
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать**

```python
async def cmd_rebuild_json(db: Database, repo):
    tasks = await db.fetch_for_json()
    content = build_tasks_json(tasks)
    repo.write_file("tasks.json", content)
    repo.commit_and_push_with_recovery("manual: rebuild tasks.json")
    print(f"wrote {len(tasks)} tasks")
```

В парсере добавить субкоманду `rebuild-json`.

- [ ] **Шаг 4: Зелёный**

**🎯 Deployable точка #1 достигнута.** На этом этапе можно вручную через CLI:
1. `tasks-cli add "купить корм" --project life --deadline 2026-05-01`
2. `tasks-cli confirm 1`
3. `tasks-cli rebuild-json`
4. Видеть `tasks.json` в GitHub-репо

Telegram ещё не подключён, но контракт `tasks.json` уже работает. План 2 можно стартовать параллельно.

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add CLI rebuild-json — Plan 1 deployable point #1"
```

---

# Фаза F — OpenRouter классификатор

## Задача F.1: HTTP-клиент с моком

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/classifier.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_classifier.py`

- [ ] **Шаг 1: Тест с моком**

```python
import json
import pytest
from datetime import date
from unittest.mock import AsyncMock, patch
from tasks_bot.classifier import classify, ClassificationResult
from tasks_bot.models import DeadlineKind

async def test_classify_extracts_project_and_deadline():
    fake_response = {
        "choices": [{"message": {"content": json.dumps({
            "project": "mars",
            "deadline_iso": "2026-05-01",
            "deadline_kind": "date"
        })}}]
    }
    with patch("tasks_bot.classifier._call_openrouter",
                new=AsyncMock(return_value=fake_response)):
        r = await classify("позвонить Ане завтра", today=date(2026, 4, 30))
    assert r.project == "mars"
    assert r.deadline == date(2026, 5, 1)
    assert r.deadline_kind == DeadlineKind.DATE

async def test_classify_falls_back_to_inbox_on_unknown_project():
    fake_response = {
        "choices": [{"message": {"content": json.dumps({
            "project": "unknown",
            "deadline_iso": None,
            "deadline_kind": "none"
        })}}]
    }
    with patch("tasks_bot.classifier._call_openrouter",
                new=AsyncMock(return_value=fake_response)):
        r = await classify("что-то", today=date(2026, 4, 30))
    assert r.project == "_inbox"

async def test_classify_falls_back_on_invalid_json():
    fake_response = {"choices": [{"message": {"content": "не JSON вообще"}}]}
    with patch("tasks_bot.classifier._call_openrouter",
                new=AsyncMock(return_value=fake_response)):
        r = await classify("что-то", today=date(2026, 4, 30))
    assert r.project == "_inbox"
    assert r.deadline_kind == DeadlineKind.NONE
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
import json
import httpx
from datetime import date
from pydantic import BaseModel
from .config import get_settings, PROJECTS
from .models import DeadlineKind

class ClassificationResult(BaseModel):
    project: str
    deadline: date | None
    deadline_kind: DeadlineKind

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

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

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

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

Ответь только валидным JSON:
{{
  "project": "mars" | "channel" | "consulting" | "life" | "_inbox",
  "deadline_iso": "YYYY-MM-DD" | null,
  "deadline_kind": "date" | "week" | "none"
}}

Задача: {task}"""

async def _call_openrouter(prompt: str) -> dict:
    settings = get_settings()
    async with httpx.AsyncClient(timeout=30.0) as client:
        resp = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={
                "Authorization": f"Bearer {settings.openrouter_api_key}",
                "Content-Type": "application/json",
            },
            json={
                "model": settings.openrouter_model,
                "messages": [{"role": "user", "content": prompt}],
                "temperature": 0.0,
            },
        )
        resp.raise_for_status()
        return resp.json()

async def classify(text: str, today: date) -> ClassificationResult:
    prompt = PROMPT_TEMPLATE.format(task=text, today=today.isoformat())
    try:
        resp = await _call_openrouter(prompt)
        content = resp["choices"][0]["message"]["content"]
        parsed = json.loads(content)
        project = parsed.get("project") if parsed.get("project") in PROJECTS else "_inbox"
        kind = DeadlineKind(parsed.get("deadline_kind", "none"))
        deadline = None
        if parsed.get("deadline_iso"):
            deadline = date.fromisoformat(parsed["deadline_iso"])
        # инвариант
        if kind == DeadlineKind.NONE:
            deadline = None
        elif deadline is None:
            kind = DeadlineKind.NONE
        return ClassificationResult(project=project, deadline=deadline,
                                     deadline_kind=kind)
    except Exception:
        return ClassificationResult(project="_inbox", deadline=None,
                                     deadline_kind=DeadlineKind.NONE)
```

- [ ] **Шаг 4: Зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add OpenRouter classifier with fallback"
```

---

## Задача F.2: Smoke-тест классификатора с реальным API (опционально, ручной)

**Files:**
- Create: `projects/vibe-coding/tasks-bot/scripts/smoke_classifier.py`

- [ ] **Шаг 1: Создать smoke-скрипт**

```python
"""Запускать вручную: python scripts/smoke_classifier.py
Проверяет реальный ответ OpenRouter."""
import asyncio
from datetime import date
from tasks_bot.classifier import classify

async def main():
    cases = [
        "позвонить Ане завтра насчёт концерта",
        "написать пост про усталость на этой неделе",
        "купить корм коту",
        "идея для контент-плана",
    ]
    for c in cases:
        r = await classify(c, today=date.today())
        print(f"{c!r:60s} -> {r.project} {r.deadline_kind} {r.deadline}")

if __name__ == "__main__":
    asyncio.run(main())
```

- [ ] **Шаг 2: Запустить вручную при наличии API-ключа, посмотреть результаты**

- [ ] **Шаг 3: Коммит**

```bash
git commit -am "tasks-bot: add manual smoke test for classifier"
```

---

## Задача F.3: Helper для расчёта недели

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/classifier.py` (или отдельный `dates.py`)
- Test: `projects/vibe-coding/tasks-bot/tests/test_dates.py`

LLM иногда промахивается с датой воскресенья — нам нужна локальная функция для расчёта.

- [ ] **Шаг 1: Тест**

`tests/test_dates.py`:
```python
from datetime import date
from tasks_bot.dates import nearest_sunday_inclusive

def test_sunday_returns_self():
    # 2026-05-03 = воскресенье
    assert nearest_sunday_inclusive(date(2026, 5, 3)) == date(2026, 5, 3)

def test_monday_returns_next_sunday():
    # 2026-04-27 = понедельник
    assert nearest_sunday_inclusive(date(2026, 4, 27)) == date(2026, 5, 3)

def test_saturday_returns_tomorrow():
    # 2026-05-02 = суббота
    assert nearest_sunday_inclusive(date(2026, 5, 2)) == date(2026, 5, 3)
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать**

`src/tasks_bot/dates.py`:
```python
from datetime import date, timedelta

def nearest_sunday_inclusive(today: date) -> date:
    # weekday(): Monday=0..Sunday=6
    days_to_sunday = (6 - today.weekday()) % 7
    return today + timedelta(days=days_to_sunday)
```

В `classify()` после парсинга: если `kind == WEEK` и `deadline` некорректный/отсутствует — пересчитать через `nearest_sunday_inclusive(today)`.

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add nearest_sunday_inclusive helper for week deadlines"
```

---

# Фаза G — Telegram-бот: захват и подтверждение

## Задача G.1: Скелет бота с проверкой owner

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/bot.py`
- Test: `projects/vibe-coding/tasks-bot/tests/test_bot.py`

Используем `python-telegram-bot` v21+. Тестировать через моки.

- [ ] **Шаг 1: Тест**

```python
from unittest.mock import AsyncMock, MagicMock
from tasks_bot.bot import is_owner

def test_is_owner_accepts_owner_id():
    update = MagicMock()
    update.effective_user.id = 123
    assert is_owner(update, owner_id=123) is True

def test_is_owner_rejects_other():
    update = MagicMock()
    update.effective_user.id = 999
    assert is_owner(update, owner_id=123) is False
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать каркас**

```python
from telegram import Update
from telegram.ext import Application, MessageHandler, filters, ContextTypes

def is_owner(update: Update, owner_id: int) -> bool:
    return update.effective_user.id == owner_id

async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    # placeholder — заполним в следующих задачах
    await update.message.reply_text("получил")

async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    # placeholder — заполним в G.4
    await update.callback_query.answer()

def build_application(token: str, owner_id: int, db) -> Application:
    from telegram.ext import CallbackQueryHandler
    app = Application.builder().token(token).build()
    app.bot_data["owner_id"] = owner_id
    app.bot_data["db"] = db
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
    app.add_handler(CallbackQueryHandler(handle_callback))
    return app
```

`db` кладётся в `bot_data` чтобы хендлеры могли его достать через `context.application.bot_data["db"]`.

- [ ] **Шаг 4: Зелёный тест is_owner**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add Telegram bot skeleton with owner check"
```

---

## Задача G.2: handle_text — классификация и сохранение pending

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/bot.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_bot.py`

- [ ] **Шаг 1: Тест**

```python
import json
from unittest.mock import AsyncMock, patch
from datetime import date
from tasks_bot.bot import handle_text_logic
from tasks_bot.models import TaskStatus

async def test_handle_text_creates_pending_task(db):
    fake_classification = {
        "project": "mars", "deadline_iso": "2026-05-01", "deadline_kind": "date"
    }
    fake_response = {"choices": [{"message": {"content": json.dumps(fake_classification)}}]}
    with patch("tasks_bot.classifier._call_openrouter",
                new=AsyncMock(return_value=fake_response)):
        reply = await handle_text_logic(db, "позвонить Ане",
                                          today=date(2026, 4, 30))

    rows = await db.fetchall("SELECT * FROM tasks")
    assert len(rows) == 1
    assert rows[0]["status"] == TaskStatus.PENDING_CONFIRMATION.value
    assert rows[0]["project"] == "mars"
    assert "Марс" in reply.text or "mars" in reply.text
    assert reply.task_id == rows[0]["id"]
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать handle_text_logic + сообщение с кнопками**

```python
from datetime import datetime, date
from dataclasses import dataclass
from .classifier import classify
from .models import TaskStatus, DeadlineKind
from .db import Database
from .clock import now_tz
from .config import get_settings

PROJECT_LABELS = {
    "mars": "Марс", "channel": "Канал", "consulting": "Консалтинг",
    "life": "Жизнь", "_inbox": "Входящие",
}
KIND_LABELS = {
    DeadlineKind.DATE: lambda d: f"📅 {d.isoformat()}",
    DeadlineKind.WEEK: lambda d: f"📅 на этой неделе (до {d.isoformat()})",
    DeadlineKind.NONE: lambda d: "без срока",
}

@dataclass
class Reply:
    text: str
    task_id: int
    needs_deadline_buttons: bool

async def handle_text_logic(db: Database, text: str, today: date) -> Reply:
    cls = await classify(text, today=today)
    tid = await db.insert_task(
        text=text, project=cls.project,
        deadline=cls.deadline, deadline_kind=cls.deadline_kind,
        status=TaskStatus.PENDING_CONFIRMATION,
        now=now_tz(get_settings().timezone),
    )
    project_label = PROJECT_LABELS[cls.project]
    deadline_label = KIND_LABELS[cls.deadline_kind](cls.deadline)
    msg = f"📝 Записал в {project_label}\n{deadline_label}\n\nВсё верно?"
    return Reply(text=msg, task_id=tid,
                 needs_deadline_buttons=cls.deadline_kind == DeadlineKind.NONE)
```

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: classify and store pending task on text message"
```

---

## Задача G.3: Inline-клавиатура подтверждения

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/bot.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_bot.py`

- [ ] **Шаг 1: Тест на формирование клавиатуры**

```python
from tasks_bot.bot import build_confirmation_keyboard

def test_keyboard_when_deadline_known():
    kb = build_confirmation_keyboard(task_id=42, needs_deadline=False)
    flat = [b.callback_data for row in kb.inline_keyboard for b in row]
    assert "ok:42" in flat
    assert "proj:42" in flat
    assert "deadline:42" in flat

def test_keyboard_when_no_deadline():
    kb = build_confirmation_keyboard(task_id=42, needs_deadline=True)
    flat = [b.callback_data for row in kb.inline_keyboard for b in row]
    assert "set:42:today" in flat
    assert "set:42:tomorrow" in flat
    assert "set:42:week" in flat
    assert "set:42:none" in flat
```

- [ ] **Шаг 2: Падает**

- [ ] **Шаг 3: Реализовать**

```python
from telegram import InlineKeyboardButton, InlineKeyboardMarkup

def build_confirmation_keyboard(task_id: int, needs_deadline: bool) -> InlineKeyboardMarkup:
    if needs_deadline:
        rows = [[
            InlineKeyboardButton("Сегодня", callback_data=f"set:{task_id}:today"),
            InlineKeyboardButton("Завтра", callback_data=f"set:{task_id}:tomorrow"),
        ], [
            InlineKeyboardButton("На неделе", callback_data=f"set:{task_id}:week"),
            InlineKeyboardButton("Без срока", callback_data=f"set:{task_id}:none"),
        ], [
            InlineKeyboardButton("Сменить проект", callback_data=f"proj:{task_id}"),
        ]]
    else:
        rows = [[
            InlineKeyboardButton("✓ ОК", callback_data=f"ok:{task_id}"),
        ], [
            InlineKeyboardButton("Сменить проект", callback_data=f"proj:{task_id}"),
            InlineKeyboardButton("Сменить срок", callback_data=f"deadline:{task_id}"),
        ]]
    return InlineKeyboardMarkup(rows)
```

В `handle_text` использовать клавиатуру в `reply_text(reply.text, reply_markup=...)`.

- [ ] **Шаг 4: Зелёный**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: add confirmation inline keyboard"
```

---

## Задача G.4: Callback handler для подтверждения

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/bot.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_bot.py`

- [ ] **Шаг 1: Тесты**

```python
async def test_callback_ok_confirms_task(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    result = await handle_callback_logic(db, f"ok:{tid}", today=date(2026, 4, 30))
    t = await db.get_task(tid)
    assert t.status == TaskStatus.CONFIRMED
    assert "✅" in result.text

async def test_callback_set_today_updates_deadline(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    await handle_callback_logic(db, f"set:{tid}:today", today=date(2026, 4, 30))
    t = await db.get_task(tid)
    assert t.deadline == date(2026, 4, 30)
    assert t.status == TaskStatus.CONFIRMED

async def test_callback_set_week_uses_nearest_sunday(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    # 2026-04-30 = четверг, ближайшее воскресенье = 2026-05-03
    await handle_callback_logic(db, f"set:{tid}:week", today=date(2026, 4, 30))
    t = await db.get_task(tid)
    assert t.deadline == date(2026, 5, 3)
    assert t.deadline_kind == DeadlineKind.WEEK

async def test_callback_set_none(db):
    tid = await db.insert_task("x", "mars", date(2026, 5, 1), DeadlineKind.DATE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    await handle_callback_logic(db, f"set:{tid}:none", today=date(2026, 4, 30))
    t = await db.get_task(tid)
    assert t.deadline is None
    assert t.deadline_kind == DeadlineKind.NONE

async def test_callback_deadline_returns_choice_keyboard(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, datetime.now())
    result = await handle_callback_logic(db, f"deadline:{tid}", today=date(2026, 4, 30))
    assert "Когда?" in result.text
    flat = [b.callback_data for row in result.keyboard.inline_keyboard for b in row]
    assert f"set:{tid}:today" in flat
    assert f"set:{tid}:tomorrow" in flat
    assert f"set:{tid}:week" in flat
    assert f"set:{tid}:none" in flat
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

```python
from datetime import date, timedelta
from dataclasses import dataclass
from .dates import nearest_sunday_inclusive

@dataclass
class CallbackResult:
    text: str
    keyboard: object | None = None  # InlineKeyboardMarkup | None

async def handle_callback_logic(db: Database, data: str, today: date) -> CallbackResult:
    parts = data.split(":")
    op = parts[0]
    tid = int(parts[1])
    now = now_tz(get_settings().timezone)

    if op == "ok":
        await db.update_status(tid, TaskStatus.CONFIRMED, now=now)
        return CallbackResult(text="✅ Добавлено")

    if op == "set":
        kind_arg = parts[2]
        if kind_arg == "today":
            deadline, kind = today, DeadlineKind.DATE
        elif kind_arg == "tomorrow":
            deadline, kind = today + timedelta(days=1), DeadlineKind.DATE
        elif kind_arg == "week":
            deadline, kind = nearest_sunday_inclusive(today), DeadlineKind.WEEK
        elif kind_arg == "none":
            deadline, kind = None, DeadlineKind.NONE
        else:
            return CallbackResult(text="неизвестный вариант")
        await db.update_deadline(tid, deadline, kind, now=now)
        await db.update_status(tid, TaskStatus.CONFIRMED, now=now)
        return CallbackResult(text="✅ Добавлено")

    if op == "deadline":
        # Кнопка "Сменить срок" — показываем выбор. Те же callbacks set:N:*,
        # которые уже обрабатываются веткой выше.
        return CallbackResult(text="Когда?",
                               keyboard=build_deadline_keyboard(tid))

    if op == "proj":
        # Шаг G.5
        return CallbackResult(text="…выбор проекта…")

    return CallbackResult(text="неизвестная команда")

def build_deadline_keyboard(task_id: int):
    """Только кнопки выбора срока, без 'Сменить проект'.
    Используется callback'ом 'deadline:N' и при первичном отсутствии срока."""
    from telegram import InlineKeyboardButton, InlineKeyboardMarkup
    rows = [[
        InlineKeyboardButton("Сегодня", callback_data=f"set:{task_id}:today"),
        InlineKeyboardButton("Завтра", callback_data=f"set:{task_id}:tomorrow"),
    ], [
        InlineKeyboardButton("На неделе", callback_data=f"set:{task_id}:week"),
        InlineKeyboardButton("Без срока", callback_data=f"set:{task_id}:none"),
    ]]
    return InlineKeyboardMarkup(rows)
```

В `bot.py` обновить `handle_callback`, чтобы он зовёт `handle_callback_logic` и **прокидывал клавиатуру**, если она есть в `CallbackResult` (для `deadline:N` и `proj:N` это критично — иначе пользователь не увидит выбор):

```python
async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not is_owner(update, context.application.bot_data["owner_id"]):
        return
    query = update.callback_query
    await query.answer()
    db = context.application.bot_data["db"]
    today = today_tz(get_settings().timezone)
    result = await handle_callback_logic(db, query.data, today=today)
    await query.edit_message_text(result.text, reply_markup=result.keyboard)
```

Импорты в `bot.py` дополнить: `from .clock import today_tz` и `from .config import get_settings`.

- [ ] **Шаг 4: Зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: handle confirmation and deadline callbacks"
```

---

## Задача G.5: Смена проекта до подтверждения

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/src/tasks_bot/bot.py`
- Modify: `projects/vibe-coding/tasks-bot/tests/test_bot.py`

- [ ] **Шаг 1: Тесты**

```python
async def test_proj_callback_shows_project_keyboard(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    result = await handle_callback_logic(db, f"proj:{tid}", today=date(2026, 4, 30))
    assert "В какой?" in result.text
    flat = [b.callback_data for row in result.keyboard.inline_keyboard for b in row]
    assert f"setproj:{tid}:mars" in flat
    assert f"setproj:{tid}:life" in flat

async def test_setproj_changes_project(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.PENDING_CONFIRMATION, datetime.now())
    await handle_callback_logic(db, f"setproj:{tid}:life", today=date(2026, 4, 30))
    t = await db.get_task(tid)
    assert t.project == "life"
    # статус остаётся pending — пользователь ещё не подтвердил
    assert t.status == TaskStatus.PENDING_CONFIRMATION

async def test_setproj_rejected_after_confirmation(db):
    tid = await db.insert_task("x", "mars", None, DeadlineKind.NONE,
                                 TaskStatus.CONFIRMED, datetime.now())
    result = await handle_callback_logic(db, f"setproj:{tid}:life", today=date(2026, 4, 30))
    assert "только до подтверждения" in result.text
    t = await db.get_task(tid)
    assert t.project == "mars"
```

- [ ] **Шаг 2: Падают**

- [ ] **Шаг 3: Реализовать**

`CallbackResult` расширить полем `keyboard: InlineKeyboardMarkup | None = None`.

```python
def build_project_keyboard(tid: int) -> InlineKeyboardMarkup:
    rows = [[
        InlineKeyboardButton("Марс", callback_data=f"setproj:{tid}:mars"),
        InlineKeyboardButton("Канал", callback_data=f"setproj:{tid}:channel"),
    ], [
        InlineKeyboardButton("Консалтинг", callback_data=f"setproj:{tid}:consulting"),
        InlineKeyboardButton("Жизнь", callback_data=f"setproj:{tid}:life"),
    ], [
        InlineKeyboardButton("Входящие", callback_data=f"setproj:{tid}:_inbox"),
    ]]
    return InlineKeyboardMarkup(rows)

# В handle_callback_logic добавить:
if op == "proj":
    return CallbackResult(text="В какой?", keyboard=build_project_keyboard(tid))

if op == "setproj":
    new_project = parts[2]
    t = await db.get_task(tid)
    if t.status != TaskStatus.PENDING_CONFIRMATION:
        return CallbackResult(text="Сменить проект можно только до подтверждения. "
                                    "Удали (/del) и пересоздай.")
    await db.update_project(tid, new_project, now=now_tz(get_settings().timezone))
    # Клавиатуру строим по реальному состоянию дедлайна задачи
    needs_deadline = t.deadline_kind == DeadlineKind.NONE
    return CallbackResult(text=f"Проект → {PROJECT_LABELS[new_project]}. Подтверди:",
                           keyboard=build_confirmation_keyboard(tid,
                                                                   needs_deadline=needs_deadline))
```

- [ ] **Шаг 4: Зелёные**

- [ ] **Шаг 5: Коммит**

```bash
git commit -am "tasks-bot: support project change before confirmation"
```

---

# Фаза H — Деплой на Pi — **deployable точка #2**

## Задача H.1: main.py — связка bot + sync workers

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/main.py`

- [ ] **Шаг 1: Реализовать**

```python
import asyncio
import logging
from .config import get_settings
from .db import Database
from .bot import build_application
from .git_sync import GitRepo, SyncWorker, BackupWorker

logging.basicConfig(level=logging.INFO,
                     format="%(asctime)s %(levelname)s %(name)s %(message)s")

async def main():
    settings = get_settings()
    db = Database(settings.db_path)
    await db.connect()
    await db.migrate()

    repo = GitRepo(settings.data_repo_path)
    sync = SyncWorker(db, repo, interval_seconds=5)
    backup = BackupWorker(settings.db_path, repo, interval_seconds=3600)

    app = build_application(settings.telegram_bot_token,
                              settings.telegram_owner_user_id, db=db)

    # запускаем фоновые воркеры
    sync_task = asyncio.create_task(sync.run())
    backup_task = asyncio.create_task(backup.run())

    try:
        await app.initialize()
        await app.start()
        await app.updater.start_polling()
        # ждём вечно
        await asyncio.Future()
    finally:
        sync.stop(); backup.stop()
        await sync_task; await backup_task
        await app.updater.stop(); await app.stop(); await app.shutdown()
        await db.close()

def run():
    asyncio.run(main())

if __name__ == "__main__":
    run()
```

- [ ] **Шаг 2: Проверить локально**

```bash
cp .env.example .env
# заполнить, в т.ч. путь к локальному data-repo (можно git-init локально)
tasks-bot
```

Написать боту тестовое сообщение, увидеть классификацию, нажать ОК, через 5 секунд проверить, что `tasks.json` в репо обновился.

- [ ] **Шаг 3: Коммит**

```bash
git commit -am "tasks-bot: add main entry point combining bot and workers"
```

---

## Задача H.2: systemd unit и инструкция деплоя

**Files:**
- Create: `projects/vibe-coding/tasks-bot/deploy/tasks-bot.service`
- Modify: `projects/vibe-coding/tasks-bot/README.md`

- [ ] **Шаг 1: Создать systemd unit**

`deploy/tasks-bot.service`:
```ini
[Unit]
Description=tasks-bot Telegram daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/tasks-bot
EnvironmentFile=/home/pi/tasks-bot/.env
ExecStart=/home/pi/tasks-bot/.venv/bin/tasks-bot
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

- [ ] **Шаг 2: Добавить в README инструкцию деплоя**

Добавить раздел:
```markdown
## Деплой на Raspberry Pi

```bash
ssh pi@raspberry
mkdir -p ~/tasks-bot
cd ~/tasks-bot
git clone <код> .
python3 -m venv .venv
.venv/bin/pip install -e .

# создать data-репо клон
git clone git@github.com:<user>/tasks-bot-data.git data-repo

# .env
cp .env.example .env
nano .env  # заполнить токены

# systemd
sudo cp deploy/tasks-bot.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tasks-bot
sudo journalctl -u tasks-bot -f
```
```

**🎯 Deployable точка #2 достигнута.** Бот работает на Pi 24/7. На входящее текстовое сообщение классифицирует, отвечает кнопками, после подтверждения через 5 секунд `tasks.json` оказывается в GitHub-репо.

- [ ] **Шаг 3: Коммит**

```bash
git commit -am "tasks-bot: add systemd unit and deployment instructions"
```

---

# Self-review (выполнить перед началом реализации)

После того, как план написан — пройтись глазами по спеке и проверить покрытие.

## Покрытие требований спеки

| Спека требует | Задача в плане |
|---|---|
| SQLite-схема с tasks и last_summary | B.2, B.4 |
| `cancelled` статус | B.1 (enum) |
| Инвариант deadline ↔ deadline_kind | B.1 (валидатор) |
| `updated_at` обновляется при любом write | B.3 |
| `tasks.json` формат | C.2, C.3 |
| `pending_confirmation` не попадает в JSON | B.3 (тест), C.3 |
| `cancelled` попадает в JSON для удаления на Mac | B.3 |
| `render_line` с `<!-- taskbot:N -->` | C.1 |
| Dirty-flag паттерн | B.5, D.2 |
| Старт-форс push | D.2 (`run` начинается с `db._mark_dirty()` — поднимает generation) |
| Recovery on push reject | D.4 |
| Backup tasks.db раз в час | D.3 |
| OpenRouter классификатор | F.1 |
| Fallback в _inbox при ошибке | F.1 (тест) |
| Неделя = ближайшее воскресенье включая сегодня | F.3 |
| Owner check для бота | G.1 |
| Кнопки подтверждения и сроков | G.2, G.3, G.4 |
| Смена проекта только до подтверждения | G.5 |
| Деплой на Pi через systemd | H.1, H.2 |

**Не покрыто в Плане 1 (отнесено в Планы 2-3):**
- Mac launchd-сервис → План 2
- Утренние/вечерние/воскресные сводки → План 3
- Команды `/done`, `/today`, `/this`, `/skip`, `/del` → План 3
- Голосовые сообщения → фаза 2 (после Плана 3)

---

## Открытые вопросы перед стартом

1. **GitHub-репо.** Создать заранее: `tasks-bot-data` приватный, добавить SSH-ключ Pi как deploy key с правом push. Иначе авторизация не сработает.
2. **OpenRouter API-ключ.** Зарегистрироваться на openrouter.ai, получить ключ. Бесплатные модели не требуют пополнения, но регистрация нужна.
3. **Telegram-бот.** Создать через `@BotFather`, получить токен. Узнать свой `user_id` через `@userinfobot`.

Эти три внешних артефакта должны быть готовы до Задачи A.2 (потому что в `.env` они нужны для запуска тестов с реальной интеграцией; для unit-тестов — только моки).

---

# Execution

После того, как план готов — переходим к одному из режимов выполнения:

**1. Subagent-Driven (рекомендую)** — фоновый агент берёт задачу за задачей, я ревьюю между ними. Быстрая итерация.

**2. Inline Execution** — выполняем задачи в этой сессии с чекпоинтами.
