---
project: vibe-coding
type: plan
date: 2026-05-02
status: ready-for-implementation
spec: "{vibe-coding} {plan} tasks-bot obsidian – 2026-04-29.md"
plan_part: "2 of 3"
depends_on: "{vibe-coding} {plan} tasks-bot план-1 pi-core – 2026-04-30.md"
---

# Tasks Bot — Plan 2: Mac Sync (tasks.json → Obsidian vault)

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

**Goal:** На Маке через launchd раз в 5 минут забирать `tasks.json` из приватного GitHub-репозитория и применять изменения к файлам `projects/<project>/tasks.md` в iCloud-вальте Наташи. Идемпотентно, однонаправленно (Pi → Mac), без записи назад.

**Architecture:** Маленький Python-сервис в существующем пакете `tasks_bot` (subpackage `mac`). Переиспользует `render_line` и модели из Плана 1, но не запускает БД, бот, OpenRouter. Локальный `state.json` хранит fingerprint каждой задачи и привязку к файлу — он же делает синк идемпотентным. Все файловые операции по `taskbot:N`, не по тексту.

**Tech Stack:** Python 3.11+, существующий проект `tasks_bot` (расширяем subpackage `mac/`), pytest. Запуск через `launchd` под пользователем Наташи.

---

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

**С Планом 1** контракт зафиксирован в спеке и в коде Плана 1:
- `tasks.json` имеет форму, описанную в спеке §«GitHub-репо». Mac-сервис читает только этот файл.
- `render_line(task) -> str` импортируется из `tasks_bot.render` (тот же модуль, что Pi). Любое изменение поведения рендера здесь — breaking change для Плана 1, фикстуры и тесты упадут.
- Тестируется на фикстурах `tests/fixtures/tasks_json/*.json`, созданных в Задаче C.4 Плана 1. Если План 1 ещё не выполнен — фикстуры можно сгенерировать руками (см. Приложение в конце).

**С пользователем (Наташей)** контракт:
- Сервис никогда не пишет в репозиторий, никогда не меняет SQLite на Pi.
- Сервис никогда не удаляет строки из `tasks.md`, кроме строк с `<!-- taskbot:N -->` для статуса `cancelled` или при смене проекта.
- Любые задачи, дописанные руками в `tasks.md`, остаются нетронутыми (нет `taskbot:N` — нет реакции). В сводки они не попадут — это ограничение MVP, см. спек §«Открытые вопросы».
- `state.json` живёт в `~/.tasks-bot-sync/state.json`. Если он удалён — следующий запуск переинициализируется без потерь (всё пересоберёт из `tasks.json`).

---

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

План 2 расширяет тот же репозиторий, что План 1. Новые файлы:

```
projects/vibe-coding/tasks-bot/
├── src/tasks_bot/
│   ├── ... (модули Плана 1)
│   └── mac/                                 ← новый subpackage
│       ├── __init__.py
│       ├── config.py                        # MacSettings: paths, project map
│       ├── state.py                         # SyncState model + load/save
│       ├── git_pull.py                      # async git fetch/reset wrapper
│       ├── vault_files.py                   # append/replace/delete по taskbot:N
│       ├── sync.py                          # алгоритм синка
│       ├── cli.py                           # точка входа: tasks-mac-sync
│       └── plist_template.py                # генератор launchd plist
├── tests/mac/
│   ├── __init__.py
│   ├── conftest.py                          # фикстуры: tmp vault, tmp repo, tmp state
│   ├── test_state.py
│   ├── test_git_pull.py
│   ├── test_vault_files.py
│   ├── test_sync.py                         # на фикстурах tasks.json
│   └── test_cli.py                          # end-to-end на fake-репо
└── deploy/
    ├── ru.dudina.tasks-bot.mac-sync.plist   # шаблон launchd plist
    └── install-mac.md                       # инструкция: plist + launchctl load
```

Дополняем `pyproject.toml`:
- В `[project.scripts]` добавляем `tasks-mac-sync = "tasks_bot.mac.cli:run"`.

---

## Фазы

- **Фаза A (3 задачи):** Скелет subpackage `mac/` и конфиг
- **Фаза B (3 задачи):** Локальный `state.json`
- **Фаза C (3 задачи):** Git pull wrapper
- **Фаза D (5 задач):** Файловые операции по `taskbot:N` + поиск строки во всех файлах вальта (нужен для rehydrate)
- **Фаза E (5 задач):** Алгоритм синка на фикстурах — **первая deployable точка** (можно прогонять на реальных fixture-файлах)
- **Фаза F (3 задачи):** CLI + end-to-end тест на временном вальте
- **Фаза G (2 задачи):** launchd plist + установка — **финальная deployable точка**

После Фазы E можно вручную дать сервису fixture-файл и увидеть, как он создаёт `tasks.md` в тестовом каталоге. После Фазы G — он же бежит в фоне раз в 5 минут с боевым вальтом.

---

# Фаза A — Скелет subpackage mac/

## Задача A.1: Создать subpackage `mac/`

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

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

- [ ] **Шаг 1: Создать каталоги**

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

- [ ] **Шаг 2: Smoke-тест что subpackage импортируется**

`tests/mac/test_smoke.py`:
```python
def test_subpackage_imports():
    import tasks_bot.mac  # noqa
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_smoke.py -v
```
Expected: PASS.

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

```bash
git add projects/vibe-coding/tasks-bot/src/tasks_bot/mac \
         projects/vibe-coding/tasks-bot/tests/mac
git commit -m "tasks-bot: scaffold mac subpackage for Plan 2"
```

---

## Задача A.2: `MacSettings` — конфиг через env

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

Mac-сервису нужны: путь к локальному клону репозитория, путь к корню вальта, путь к state.json. Используем тот же паттерн, что в Плане 1 — `pydantic-settings` + `@lru_cache get_mac_settings()`, чтобы не было гонки с monkeypatch на env.

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

`tests/mac/test_config.py`:
```python
import os
import pytest
from pathlib import Path
from tasks_bot.mac.config import MacSettings, get_mac_settings, PROJECT_TO_VAULT_PATH

def test_settings_from_env(monkeypatch, tmp_path):
    repo = tmp_path / "repo"; repo.mkdir()
    vault = tmp_path / "vault"; vault.mkdir()
    state = tmp_path / "state.json"

    monkeypatch.setenv("TASKS_BOT_REPO_PATH", str(repo))
    monkeypatch.setenv("TASKS_BOT_VAULT_PATH", str(vault))
    monkeypatch.setenv("TASKS_BOT_STATE_PATH", str(state))

    get_mac_settings.cache_clear()
    s = get_mac_settings()
    assert s.repo_path == repo
    assert s.vault_path == vault
    assert s.state_path == state

def test_project_to_vault_path_covers_all():
    expected = {"mars", "channel", "consulting", "life", "_inbox"}
    assert set(PROJECT_TO_VAULT_PATH.keys()) == expected
    # Все пути относительные, начинаются с projects/ или _inbox/
    for project, rel_path in PROJECT_TO_VAULT_PATH.items():
        assert rel_path.endswith("tasks.md")
        assert "/" in rel_path
```

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

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

`src/tasks_bot/mac/config.py`:
```python
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

# Маппинг проектов в относительные пути к tasks.md внутри вальта.
# Должен совпадать со значениями поля `project` в tasks.json.
PROJECT_TO_VAULT_PATH: dict[str, str] = {
    "mars": "projects/mars/tasks.md",
    "channel": "projects/channel/tasks.md",
    "consulting": "projects/consulting/tasks.md",
    "life": "projects/life/tasks.md",
    "_inbox": "_inbox/tasks.md",
}

class MacSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="TASKS_BOT_", env_file=None)

    repo_path: Path = Field(description="Локальный клон tasks-bot-data")
    vault_path: Path = Field(description="Корень Obsidian-вальта")
    state_path: Path = Field(
        default=Path.home() / ".tasks-bot-sync" / "state.json",
        description="Путь к локальному state.json"
    )

@lru_cache
def get_mac_settings() -> MacSettings:
    return MacSettings()  # type: ignore[call-arg]
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_config.py -v
```

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

```bash
git commit -am "tasks-bot/mac: add MacSettings and project->vault path map"
```

---

## Задача A.3: Дописать `pyproject.toml` — entrypoint и зависимость

**Files:**
- Modify: `projects/vibe-coding/tasks-bot/pyproject.toml`

- [ ] **Шаг 1: Добавить entrypoint в `[project.scripts]`**

В `[project.scripts]` рядом с `tasks-bot` и `tasks-cli` добавить:
```toml
tasks-mac-sync = "tasks_bot.mac.cli:run"
```

(Если файл `cli.py` ещё не создан — это нормально, console-script регистрируется при установке пакета. Создадим в Фазе F.)

- [ ] **Шаг 2: Переустановить пакет в dev-режиме (если стоит локально)**

```bash
cd projects/vibe-coding/tasks-bot && pip install -e ".[dev]"
```

Expected: установка проходит без ошибок. Команда `tasks-mac-sync` появится в PATH (но при запуске пока упадёт — нет `cli.py`).

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

```bash
git commit -am "tasks-bot: add tasks-mac-sync entrypoint to pyproject.toml"
```

---

# Фаза B — Локальный state.json

## Задача B.1: Модель `SyncState`

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/state.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_state.py`

Локальный стейт хранит fingerprint каждой задачи и информацию «была ли строка в файле и где». Формат — JSON, ключ — строковый id задачи (JSON не любит int-ключи).

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

`tests/mac/test_state.py`:
```python
import json
from pathlib import Path
from tasks_bot.mac.state import SyncState, TaskSnapshot, load_state, save_state

def test_empty_state_serialization():
    s = SyncState(tasks={})
    assert s.model_dump() == {"tasks": {}}

def test_snapshot_fingerprint_equality():
    a = TaskSnapshot(
        status="confirmed", text="x", project="mars",
        deadline="2026-05-01", deadline_kind="date",
        done_at=None, file_path="projects/mars/tasks.md", in_file=True
    )
    b = a.model_copy()
    assert a.fingerprint() == b.fingerprint()

def test_snapshot_fingerprint_changes_on_status():
    a = TaskSnapshot(
        status="confirmed", text="x", project="mars",
        deadline=None, deadline_kind="none",
        done_at=None, file_path="projects/mars/tasks.md", in_file=True
    )
    b = a.model_copy(update={"status": "done", "done_at": "2026-05-01T10:00:00+05:00"})
    assert a.fingerprint() != b.fingerprint()

def test_load_missing_file_returns_empty(tmp_path):
    p = tmp_path / "state.json"
    s = load_state(p)
    assert s.tasks == {}

def test_round_trip(tmp_path):
    p = tmp_path / "nested" / "state.json"
    s = SyncState(tasks={
        "47": TaskSnapshot(
            status="confirmed", text="позвонить Ане", project="mars",
            deadline="2026-05-01", deadline_kind="date",
            done_at=None, file_path="projects/mars/tasks.md", in_file=True
        )
    })
    save_state(p, s)  # должен создать каталог
    assert p.exists()
    loaded = load_state(p)
    assert loaded.tasks == s.tasks
```

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

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

`src/tasks_bot/mac/state.py`:
```python
import json
from pathlib import Path
from typing import Optional
from pydantic import BaseModel

class TaskSnapshot(BaseModel):
    """Снапшот задачи + информация о её привязке к файлу вальта.

    Все поля задачи — в строковом представлении, как они приходят из tasks.json.
    Это упрощает сравнение fingerprint без забот про timezone и тип date vs datetime.
    """
    status: str
    text: str
    project: str
    deadline: Optional[str]        # "YYYY-MM-DD" или None
    deadline_kind: str             # "date" | "week" | "none"
    done_at: Optional[str]         # ISO datetime или None
    file_path: Optional[str]       # относительный путь от корня вальта; None если не записано
    in_file: bool                  # есть ли строка в файле прямо сейчас

    def fingerprint(self) -> tuple:
        """Поля, по которым решаем «изменилась задача или нет».
        file_path и in_file НЕ входят — это локальное состояние, не часть задачи."""
        return (self.status, self.text, self.project,
                self.deadline, self.deadline_kind, self.done_at)

class SyncState(BaseModel):
    """Полный локальный стейт. Ключи tasks — строковые id (для JSON-совместимости)."""
    tasks: dict[str, TaskSnapshot]

def load_state(path: Path) -> SyncState:
    if not path.exists():
        return SyncState(tasks={})
    raw = json.loads(path.read_text())
    return SyncState.model_validate(raw)

def save_state(path: Path, state: SyncState) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state.model_dump(), ensure_ascii=False, indent=2))
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_state.py -v
```

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

```bash
git commit -am "tasks-bot/mac: SyncState model with fingerprint and JSON I/O"
```

---

## Задача B.2: Атомарная запись `state.json`

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

Если launchd прервёт процесс посреди записи — получим битый JSON и состояние развалится. Пишем через временный файл + `os.replace` (атомарно на macOS APFS).

- [ ] **Шаг 1: Тест на атомарность**

В `tests/mac/test_state.py` добавить:
```python
import os

def test_save_is_atomic_via_temp_file(tmp_path, monkeypatch):
    p = tmp_path / "state.json"
    p.write_text('{"tasks": {}}')  # уже что-то есть

    # Перехватываем os.replace и проверяем, что запись идёт во временный файл
    real_replace = os.replace
    calls = []
    def fake_replace(src, dst):
        calls.append((str(src), str(dst)))
        real_replace(src, dst)
    monkeypatch.setattr("tasks_bot.mac.state.os.replace", fake_replace)

    s = SyncState(tasks={
        "1": TaskSnapshot(
            status="confirmed", text="t", project="life",
            deadline=None, deadline_kind="none", done_at=None,
            file_path="projects/life/tasks.md", in_file=True
        )
    })
    save_state(p, s)
    assert len(calls) == 1
    src, dst = calls[0]
    assert dst == str(p)
    assert src.endswith(".tmp")
```

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

- [ ] **Шаг 3: Переписать `save_state`**

```python
import os
# ...

def save_state(path: Path, state: SyncState) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(state.model_dump(), ensure_ascii=False, indent=2))
    os.replace(tmp, path)
```

- [ ] **Шаг 4: Зелёный + старые тесты не сломались**

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_state.py -v
```

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

```bash
git commit -am "tasks-bot/mac: atomic state.json write via temp file + os.replace"
```

---

## Задача B.3: Парсер `tasks.json` → список «сырых» задач

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/tasks_json_reader.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_tasks_json_reader.py`

Mac-сторона работает не с моделью `Task` из Плана 1 (там datetime, date, enum), а с лёгкой строковой формой `RawTask` — потому что от Плана 1 нам нужен только сам JSON, а не «пакетная зависимость от Pi-моделей через monkey-patched datetime». Это упрощает тесты и убирает разбор типов.

`RawTask` хранит ровно те же поля, что были сериализованы в `tasks.json`.

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

`tests/mac/test_tasks_json_reader.py`:
```python
import json
from pathlib import Path
from tasks_bot.mac.tasks_json_reader import RawTask, read_tasks_json

def test_read_empty(tmp_path):
    p = tmp_path / "tasks.json"
    p.write_text("[]")
    assert read_tasks_json(p) == []

def test_read_single(tmp_path):
    p = tmp_path / "tasks.json"
    p.write_text(json.dumps([{
        "id": 47, "text": "позвонить Ане", "project": "mars",
        "deadline": "2026-05-01", "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": None,
    }]))
    out = read_tasks_json(p)
    assert len(out) == 1
    assert out[0].id == 47
    assert out[0].text == "позвонить Ане"
    assert out[0].deadline == "2026-05-01"
    assert out[0].status == "confirmed"

def test_missing_file_returns_empty(tmp_path):
    assert read_tasks_json(tmp_path / "nope.json") == []
```

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

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

`src/tasks_bot/mac/tasks_json_reader.py`:
```python
import json
from pathlib import Path
from typing import Optional
from pydantic import BaseModel

class RawTask(BaseModel):
    """Точное отображение задачи из tasks.json. Все даты — строки, без парсинга."""
    id: int
    text: str
    project: str
    deadline: Optional[str]
    deadline_kind: str
    status: str
    created_at: str
    updated_at: str
    confirmed_at: Optional[str]
    done_at: Optional[str]

def read_tasks_json(path: Path) -> list[RawTask]:
    if not path.exists():
        return []
    payload = json.loads(path.read_text())
    return [RawTask.model_validate(item) for item in payload]
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_tasks_json_reader.py -v
```

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

```bash
git commit -am "tasks-bot/mac: RawTask model and read_tasks_json"
```

---

# Фаза C — Git pull wrapper

## Задача C.1: `git_pull` — асинхронный fetch + reset

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/git_pull.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_git_pull.py`

Mac никогда не пишет в репо, его задача — просто иметь актуальный `tasks.json`. Используем `git fetch + git reset --hard origin/main`, а не `git pull --ff-only`. Причина: если по какой-то причине в локальном клоне появились мусорные коммиты (рукой ткнули, например), `pull` может уйти в merge-конфликт; `reset --hard` всегда даёт состояние, идентичное remote. Mac никогда не теряет работу — он не делает работы.

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

`tests/mac/test_git_pull.py`:
```python
import asyncio
import subprocess
from pathlib import Path
import pytest
from tasks_bot.mac.git_pull import git_pull

@pytest.fixture
def tmp_remote_and_clone(tmp_path):
    """Создаёт bare-репо как 'remote' и обычный клон рядом."""
    remote = tmp_path / "remote.git"
    work = tmp_path / "seed"
    clone = tmp_path / "clone"

    subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], check=True)
    subprocess.run(["git", "clone", str(remote), str(work)], check=True)
    (work / "tasks.json").write_text("[]")
    subprocess.run(["git", "-C", str(work), "add", "."], check=True)
    subprocess.run(["git", "-C", str(work), "-c", "user.email=t@t", "-c", "user.name=t",
                     "commit", "-m", "init"], check=True)
    subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)

    subprocess.run(["git", "clone", str(remote), str(clone)], check=True)
    return remote, work, clone

@pytest.mark.asyncio
async def test_pull_fast_forward(tmp_remote_and_clone):
    remote, work, clone = tmp_remote_and_clone
    # Обновим remote
    (work / "tasks.json").write_text('[{"id":1}]')
    subprocess.run(["git", "-C", str(work), "commit", "-am", "update"], check=True)
    subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)

    await git_pull(clone)
    assert '"id":1' in (clone / "tasks.json").read_text()

@pytest.mark.asyncio
async def test_pull_resets_local_drift(tmp_remote_and_clone):
    """Если в локальном клоне «грязь» — её снесёт reset --hard, не упадёт."""
    remote, _work, clone = tmp_remote_and_clone
    (clone / "tasks.json").write_text("LOCAL JUNK")  # рука дрогнула

    await git_pull(clone)
    assert (clone / "tasks.json").read_text() == "[]"

@pytest.mark.asyncio
async def test_pull_raises_if_not_a_repo(tmp_path):
    not_repo = tmp_path / "x"
    not_repo.mkdir()
    with pytest.raises(Exception):
        await git_pull(not_repo)
```

- [ ] **Шаг 2: Падают (нет модуля)**

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

`src/tasks_bot/mac/git_pull.py`:
```python
import asyncio
import subprocess
from pathlib import Path

async def git_pull(repo_path: Path) -> None:
    """Привести локальный клон к состоянию origin/main.

    Не делает merge: использует fetch + reset --hard, чтобы локальный мусор
    не блокировал апдейт. Mac не должен ничего хранить локально что не пришло
    из tasks.json.
    """
    await _git(repo_path, "fetch", "origin", "main")
    await _git(repo_path, "reset", "--hard", "origin/main")

async def _git(repo_path: Path, *args: str) -> None:
    """Тонкая обёртка: git -C repo_path <args>, бросает CalledProcessError."""
    cmd = ["git", "-C", str(repo_path), *args]
    await asyncio.to_thread(subprocess.run, cmd, check=True, capture_output=True)
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_git_pull.py -v
```

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

```bash
git commit -am "tasks-bot/mac: git_pull via fetch + reset --hard"
```

---

## Задача C.2: Мягкий `git_pull` — не падать на сетевых ошибках

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

Если интернета нет / GitHub недоступен — sync должен **тихо использовать локальную копию**, а не валить весь процесс с traceback. Возвращаем bool: True если успешно подтянули, False если упали.

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

В `tests/mac/test_git_pull.py` добавить:
```python
@pytest.mark.asyncio
async def test_try_pull_returns_false_on_failure(tmp_path):
    not_repo = tmp_path / "x"
    not_repo.mkdir()
    from tasks_bot.mac.git_pull import try_git_pull
    ok = await try_git_pull(not_repo)
    assert ok is False

@pytest.mark.asyncio
async def test_try_pull_returns_true_on_success(tmp_remote_and_clone):
    _remote, _work, clone = tmp_remote_and_clone
    from tasks_bot.mac.git_pull import try_git_pull
    ok = await try_git_pull(clone)
    assert ok is True
```

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

- [ ] **Шаг 3: Добавить `try_git_pull`**

В `src/tasks_bot/mac/git_pull.py` добавить:
```python
import logging
log = logging.getLogger(__name__)

async def try_git_pull(repo_path: Path) -> bool:
    """Вызвать git_pull, проглотить ошибки. Залогировать, вернуть True/False."""
    try:
        await git_pull(repo_path)
        return True
    except subprocess.CalledProcessError as e:
        log.warning("git pull failed: %s. stderr=%s", e, e.stderr.decode(errors="replace") if e.stderr else "")
        return False
    except Exception as e:
        log.warning("git pull failed: %s", e)
        return False
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_git_pull.py -v
```

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

```bash
git commit -am "tasks-bot/mac: try_git_pull swallows errors and returns bool"
```

---

## Задача C.3: Lock-файл — не запускать sync поверх sync

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/lockfile.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_lockfile.py`

launchd может запустить новый инстанс через 5 минут, даже если предыдущий ещё не закончил (например, тормозит git над VPN). Делаем простой POSIX-лок через `fcntl.flock` в `~/.tasks-bot-sync/sync.lock`.

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

`tests/mac/test_lockfile.py`:
```python
import multiprocessing
import time
from pathlib import Path
from tasks_bot.mac.lockfile import sync_lock, LockBusy

def test_lock_acquired_and_released(tmp_path):
    p = tmp_path / "sync.lock"
    with sync_lock(p):
        assert p.exists()
    # После выхода — можно взять снова
    with sync_lock(p):
        pass

def _hold_lock(path, ready_event, release_event):
    from tasks_bot.mac.lockfile import sync_lock
    with sync_lock(Path(path)):
        ready_event.set()
        release_event.wait(timeout=5)

def test_second_lock_raises_lock_busy(tmp_path):
    p = tmp_path / "sync.lock"
    ready = multiprocessing.Event()
    release = multiprocessing.Event()
    proc = multiprocessing.Process(target=_hold_lock, args=(str(p), ready, release))
    proc.start()
    try:
        ready.wait(timeout=5)
        import pytest
        with pytest.raises(LockBusy):
            with sync_lock(p):
                pass
    finally:
        release.set()
        proc.join(timeout=5)
```

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

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

`src/tasks_bot/mac/lockfile.py`:
```python
import fcntl
from contextlib import contextmanager
from pathlib import Path

class LockBusy(RuntimeError):
    """Другой инстанс уже держит лок."""

@contextmanager
def sync_lock(lock_path: Path):
    """Эксклюзивный лок на время блока. Бросает LockBusy если занят."""
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    f = open(lock_path, "w")
    try:
        try:
            fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            f.close()
            raise LockBusy(f"Lock is held: {lock_path}")
        yield
    finally:
        try:
            fcntl.flock(f, fcntl.LOCK_UN)
        finally:
            f.close()
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_lockfile.py -v
```

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

```bash
git commit -am "tasks-bot/mac: fcntl-based sync lockfile to avoid concurrent runs"
```

---

# Фаза D — Файловые операции по taskbot:N

## Задача D.1: `find_line_index_by_id` — найти строку по `taskbot:N`

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/vault_files.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_vault_files.py`

Все операции на файле адресуются через `<!-- taskbot:N -->`. Этот же модуль будет дописывать, заменять и удалять строки. Сначала — поисковая примитива.

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

`tests/mac/test_vault_files.py`:
```python
from pathlib import Path
from tasks_bot.mac.vault_files import find_line_index_by_id

def test_find_existing(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text(
        "- [ ] первая <!-- taskbot:1 -->\n"
        "- [ ] вторая <!-- taskbot:42 -->\n"
        "- [ ] третья <!-- taskbot:7 -->\n"
    )
    assert find_line_index_by_id(p, 42) == 1

def test_find_missing_returns_none(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("- [ ] одна <!-- taskbot:1 -->\n")
    assert find_line_index_by_id(p, 99) is None

def test_find_in_missing_file_returns_none(tmp_path):
    assert find_line_index_by_id(tmp_path / "nope.md", 1) is None
```

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

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

`src/tasks_bot/mac/vault_files.py`:
```python
from pathlib import Path
from tasks_bot.render import parse_taskbot_id

def find_line_index_by_id(file_path: Path, task_id: int) -> int | None:
    """Вернуть индекс строки (0-based) с маркером taskbot:N или None."""
    if not file_path.exists():
        return None
    for i, line in enumerate(file_path.read_text().splitlines()):
        if parse_taskbot_id(line) == task_id:
            return i
    return None
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_vault_files.py -v
```

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

```bash
git commit -am "tasks-bot/mac: find_line_index_by_id via shared parse_taskbot_id"
```

---

## Задача D.2: `append_line` — дописать в файл, создать если нет

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

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

В `tests/mac/test_vault_files.py` добавить:
```python
from tasks_bot.mac.vault_files import append_line

def test_append_to_existing(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("- [ ] one <!-- taskbot:1 -->\n")
    append_line(p, "- [ ] two <!-- taskbot:2 -->")
    assert p.read_text() == (
        "- [ ] one <!-- taskbot:1 -->\n"
        "- [ ] two <!-- taskbot:2 -->\n"
    )

def test_append_creates_file_with_parents(tmp_path):
    p = tmp_path / "projects" / "mars" / "tasks.md"
    append_line(p, "- [ ] первая <!-- taskbot:1 -->")
    assert p.read_text() == "- [ ] первая <!-- taskbot:1 -->\n"

def test_append_handles_missing_trailing_newline(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("- [ ] one <!-- taskbot:1 -->")  # без \n в конце
    append_line(p, "- [ ] two <!-- taskbot:2 -->")
    assert p.read_text() == (
        "- [ ] one <!-- taskbot:1 -->\n"
        "- [ ] two <!-- taskbot:2 -->\n"
    )

def test_append_to_empty_file(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("")
    append_line(p, "- [ ] first <!-- taskbot:1 -->")
    assert p.read_text() == "- [ ] first <!-- taskbot:1 -->\n"
```

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

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

В `src/tasks_bot/mac/vault_files.py` добавить:
```python
def append_line(file_path: Path, line: str) -> None:
    """Дописать строку в файл (создать с родителями если нет).
    Гарантирует \\n между предыдущим контентом и новой строкой."""
    file_path.parent.mkdir(parents=True, exist_ok=True)
    if file_path.exists():
        existing = file_path.read_text()
        if existing and not existing.endswith("\n"):
            existing += "\n"
        file_path.write_text(existing + line + "\n")
    else:
        file_path.write_text(line + "\n")
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_vault_files.py -v
```

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

```bash
git commit -am "tasks-bot/mac: append_line creates parents and handles missing newline"
```

---

## Задача D.3: `replace_line_by_id` — переписать строку по taskbot:N

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

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

В `tests/mac/test_vault_files.py` добавить:
```python
from tasks_bot.mac.vault_files import replace_line_by_id, LineNotFound

def test_replace_existing(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text(
        "- [ ] первая <!-- taskbot:1 -->\n"
        "- [ ] вторая <!-- taskbot:2 -->\n"
        "- [ ] третья <!-- taskbot:3 -->\n"
    )
    replace_line_by_id(p, 2, "- [x] вторая ✅ 2026-05-02 <!-- taskbot:2 -->")
    assert p.read_text() == (
        "- [ ] первая <!-- taskbot:1 -->\n"
        "- [x] вторая ✅ 2026-05-02 <!-- taskbot:2 -->\n"
        "- [ ] третья <!-- taskbot:3 -->\n"
    )

def test_replace_missing_raises(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("- [ ] one <!-- taskbot:1 -->\n")
    import pytest
    with pytest.raises(LineNotFound):
        replace_line_by_id(p, 99, "- [ ] x <!-- taskbot:99 -->")

def test_replace_preserves_user_lines_around(tmp_path):
    """Между задачами могут быть руками вписанные пункты — не трогаем."""
    p = tmp_path / "tasks.md"
    p.write_text(
        "- [ ] one <!-- taskbot:1 -->\n"
        "- [ ] руками вписала, без id\n"
        "- [ ] two <!-- taskbot:2 -->\n"
    )
    replace_line_by_id(p, 1, "- [x] one ✅ 2026-05-02 <!-- taskbot:1 -->")
    assert p.read_text() == (
        "- [x] one ✅ 2026-05-02 <!-- taskbot:1 -->\n"
        "- [ ] руками вписала, без id\n"
        "- [ ] two <!-- taskbot:2 -->\n"
    )
```

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

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

В `src/tasks_bot/mac/vault_files.py` добавить:
```python
class LineNotFound(LookupError):
    pass

def replace_line_by_id(file_path: Path, task_id: int, new_line: str) -> None:
    if not file_path.exists():
        raise LineNotFound(f"File missing: {file_path}")
    lines = file_path.read_text().splitlines()
    idx = None
    for i, line in enumerate(lines):
        if parse_taskbot_id(line) == task_id:
            idx = i
            break
    if idx is None:
        raise LineNotFound(f"taskbot:{task_id} not found in {file_path}")
    lines[idx] = new_line
    file_path.write_text("\n".join(lines) + "\n")
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_vault_files.py -v
```

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

```bash
git commit -am "tasks-bot/mac: replace_line_by_id with LineNotFound on miss"
```

---

## Задача D.4: `delete_line_by_id` — стереть строку, не падать если нет

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

Для cancelled-задач: если файла нет или строки нет — это не ошибка, это «уже удалено» (идемпотентность).

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

В `tests/mac/test_vault_files.py` добавить:
```python
from tasks_bot.mac.vault_files import delete_line_by_id

def test_delete_existing(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text(
        "- [ ] one <!-- taskbot:1 -->\n"
        "- [ ] two <!-- taskbot:2 -->\n"
        "- [ ] three <!-- taskbot:3 -->\n"
    )
    delete_line_by_id(p, 2)
    assert p.read_text() == (
        "- [ ] one <!-- taskbot:1 -->\n"
        "- [ ] three <!-- taskbot:3 -->\n"
    )

def test_delete_missing_id_is_noop(tmp_path):
    p = tmp_path / "tasks.md"
    p.write_text("- [ ] one <!-- taskbot:1 -->\n")
    delete_line_by_id(p, 99)  # не падает
    assert p.read_text() == "- [ ] one <!-- taskbot:1 -->\n"

def test_delete_in_missing_file_is_noop(tmp_path):
    delete_line_by_id(tmp_path / "nope.md", 1)  # не падает
```

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

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

В `src/tasks_bot/mac/vault_files.py` добавить:
```python
def delete_line_by_id(file_path: Path, task_id: int) -> None:
    """Удалить строку с маркером taskbot:N. Идемпотентно (нет файла/нет строки → no-op)."""
    if not file_path.exists():
        return
    lines = file_path.read_text().splitlines()
    new = [line for line in lines if parse_taskbot_id(line) != task_id]
    if len(new) == len(lines):
        return
    if new:
        file_path.write_text("\n".join(new) + "\n")
    else:
        file_path.write_text("")
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_vault_files.py -v
```

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

```bash
git commit -am "tasks-bot/mac: idempotent delete_line_by_id"
```

---

## Задача D.5: `find_existing_file_for_id` — найти, в каком файле уже лежит задача

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

**Зачем:** если `state.json` удалили (или его никогда не было — первый запуск с уже существующим вальтом), а задача в `tasks.md` уже есть, то наивный «нет prior — append» дописал бы строку второй раз. Перед аппендом надо искать строку во всех файлах вальта.

Возвращает относительный путь к файлу или None.

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

В `tests/mac/test_vault_files.py` добавить:
```python
from tasks_bot.mac.vault_files import find_existing_file_for_id

def test_find_existing_file_returns_relative_path(tmp_path):
    f = tmp_path / "projects" / "mars" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] x <!-- taskbot:47 -->\n")
    rel = find_existing_file_for_id(47, vault_root=tmp_path,
                                       candidate_rel_paths=["projects/mars/tasks.md",
                                                              "projects/life/tasks.md"])
    assert rel == "projects/mars/tasks.md"

def test_find_existing_returns_none_if_nowhere(tmp_path):
    f = tmp_path / "projects" / "mars" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] x <!-- taskbot:47 -->\n")
    rel = find_existing_file_for_id(99, vault_root=tmp_path,
                                       candidate_rel_paths=["projects/mars/tasks.md"])
    assert rel is None

def test_find_existing_skips_missing_files(tmp_path):
    """Если файла из списка нет — не падать, просто пропустить."""
    rel = find_existing_file_for_id(1, vault_root=tmp_path,
                                       candidate_rel_paths=["projects/mars/tasks.md",
                                                              "_inbox/tasks.md"])
    assert rel is None

def test_find_existing_returns_first_match_in_candidate_order(tmp_path):
    """Если задачка дублируется в двух файлах (не должно быть, но защищаемся) —
    возвращаем первый по порядку candidate_rel_paths."""
    a = tmp_path / "projects" / "mars" / "tasks.md"; a.parent.mkdir(parents=True)
    b = tmp_path / "projects" / "life" / "tasks.md"; b.parent.mkdir(parents=True)
    a.write_text("- [ ] dup <!-- taskbot:47 -->\n")
    b.write_text("- [ ] dup <!-- taskbot:47 -->\n")
    rel = find_existing_file_for_id(47, vault_root=tmp_path,
                                       candidate_rel_paths=["projects/mars/tasks.md",
                                                              "projects/life/tasks.md"])
    assert rel == "projects/mars/tasks.md"
```

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

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

В `src/tasks_bot/mac/vault_files.py` добавить:
```python
def find_existing_file_for_id(
    task_id: int,
    vault_root: Path,
    candidate_rel_paths: list[str],
) -> str | None:
    """Пройти по списку относительных путей, вернуть тот, в котором есть строка с taskbot:N.
    Нужно для случая «state.json потеряли, а файлы вальта остались» — чтобы не дублировать."""
    for rel in candidate_rel_paths:
        idx = find_line_index_by_id(vault_root / rel, task_id)
        if idx is not None:
            return rel
    return None
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_vault_files.py -v
```

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

```bash
git commit -am "tasks-bot/mac: find_existing_file_for_id for state-rehydrate scenarios"
```

---

# Фаза E — Алгоритм синка

## Задача E.1: Конструктор `TaskSnapshot` из `RawTask`

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

Нужна функция `snapshot_from(raw, file_path, in_file)` — единая точка перевода `RawTask` → `TaskSnapshot`. Без неё в каждом случае алгоритма синка пришлось бы повторять список полей.

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

В `tests/mac/test_state.py` добавить:
```python
from tasks_bot.mac.tasks_json_reader import RawTask
from tasks_bot.mac.state import snapshot_from

def _raw(**kw):
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind="none", status="confirmed",
        created_at="2026-05-02T10:00:00+05:00",
        updated_at="2026-05-02T10:00:00+05:00",
        confirmed_at="2026-05-02T10:00:00+05:00",
        done_at=None,
    )
    base.update(kw); return RawTask(**base)

def test_snapshot_from_active():
    s = snapshot_from(_raw(id=47, text="t"), file_path="projects/life/tasks.md", in_file=True)
    assert s.text == "t"; assert s.project == "life"
    assert s.in_file is True; assert s.file_path == "projects/life/tasks.md"

def test_snapshot_from_cancelled():
    s = snapshot_from(_raw(status="cancelled"), file_path=None, in_file=False)
    assert s.status == "cancelled"; assert s.file_path is None; assert s.in_file is False
```

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

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

В `src/tasks_bot/mac/state.py` добавить:
```python
from tasks_bot.mac.tasks_json_reader import RawTask  # noqa: E402  (импорт внизу чтобы избежать циклов)

def snapshot_from(raw: RawTask, file_path: Optional[str], in_file: bool) -> TaskSnapshot:
    return TaskSnapshot(
        status=raw.status,
        text=raw.text,
        project=raw.project,
        deadline=raw.deadline,
        deadline_kind=raw.deadline_kind,
        done_at=raw.done_at,
        file_path=file_path,
        in_file=in_file,
    )
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_state.py -v
```

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

```bash
git commit -am "tasks-bot/mac: snapshot_from(RawTask, ...) factory"
```

---

## Задача E.2: `render_line_for_raw` — рендер из `RawTask` без зависимости от Task

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/render_raw.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_render_raw.py`

Функция `render_line` из Плана 1 берёт `Task` (с `date`, `datetime`, enum). Mac-сторона работает с `RawTask` (всё строки). Чтобы не дублировать формат, `render_line_for_raw` собирает `Task` из `RawTask` и вызывает `render_line` Плана 1.

**Это критически важный модуль:** контракт между Pi и Маком зафиксирован тестом, что для одного и того же набора полей оба генератора дают одну и ту же строку.

- [ ] **Шаг 1: Тесты — golden строки и контракт с Планом 1**

`tests/mac/test_render_raw.py`:
```python
from datetime import datetime, date
from tasks_bot.models import Task, TaskStatus, DeadlineKind
from tasks_bot.render import render_line
from tasks_bot.mac.tasks_json_reader import RawTask
from tasks_bot.mac.render_raw import render_line_for_raw

def _raw(**kw):
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind="none", status="confirmed",
        created_at="2026-05-02T10:00:00+05:00",
        updated_at="2026-05-02T10:00:00+05:00",
        confirmed_at="2026-05-02T10:00:00+05:00",
        done_at=None,
    )
    base.update(kw); return RawTask(**base)

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

def test_render_with_deadline():
    assert render_line_for_raw(_raw(id=42, text="позвонить Ане",
                                       deadline="2026-05-01", deadline_kind="date")) == \
        "- [ ] позвонить Ане 📅 2026-05-01 <!-- taskbot:42 -->"

def test_render_done():
    raw = _raw(id=42, text="купить корм", deadline="2026-04-29", deadline_kind="date",
                status="done", done_at="2026-04-29T14:30:00+05:00")
    assert render_line_for_raw(raw) == \
        "- [x] купить корм 📅 2026-04-29 ✅ 2026-04-29 <!-- taskbot:42 -->"

def test_contract_with_plan1_render_line():
    """Тот же набор полей → тот же результат, что render_line из Плана 1.
    Если падает — где-то рассинхрон, искать причину."""
    raw = _raw(id=42, text="t", deadline="2026-05-01", deadline_kind="date",
                status="done", done_at="2026-04-29T14:30:00+05:00")
    pi_task = Task(
        id=42, text="t", project="life",
        deadline=date(2026, 5, 1), deadline_kind=DeadlineKind.DATE,
        status=TaskStatus.DONE,
        created_at=datetime.fromisoformat(raw.created_at),
        updated_at=datetime.fromisoformat(raw.updated_at),
        confirmed_at=datetime.fromisoformat(raw.confirmed_at),
        done_at=datetime.fromisoformat(raw.done_at),
    )
    assert render_line_for_raw(raw) == render_line(pi_task)
```

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

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

`src/tasks_bot/mac/render_raw.py`:
```python
from datetime import datetime, date
from tasks_bot.models import Task, TaskStatus, DeadlineKind
from tasks_bot.render import render_line
from tasks_bot.mac.tasks_json_reader import RawTask

def render_line_for_raw(raw: RawTask) -> str:
    """Сконструировать Task из RawTask и вызвать общий render_line.
    Гарантирует, что Pi и Mac генерируют одинаковые строки для одинаковых полей."""
    task = Task(
        id=raw.id,
        text=raw.text,
        project=raw.project,
        deadline=date.fromisoformat(raw.deadline) if raw.deadline else None,
        deadline_kind=DeadlineKind(raw.deadline_kind),
        status=TaskStatus(raw.status),
        created_at=datetime.fromisoformat(raw.created_at),
        updated_at=datetime.fromisoformat(raw.updated_at),
        confirmed_at=datetime.fromisoformat(raw.confirmed_at) if raw.confirmed_at else None,
        done_at=datetime.fromisoformat(raw.done_at) if raw.done_at else None,
    )
    return render_line(task)
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_render_raw.py -v
```

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

```bash
git commit -am "tasks-bot/mac: render_line_for_raw delegates to shared render_line"
```

---

## Задача E.3: `apply_one` — алгоритм для одной задачи

**Files:**
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/sync.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_sync.py`

Функция чистая в том смысле, что **все файловые операции** делает явно — но возвращает обновлённый `TaskSnapshot`. Тестируется на временном вальте.

Полный алгоритм:

```
если T.status == "cancelled":
    если S есть и S.in_file: удалить из S.file_path
    иначе если S нет (state потерян): искать строку во всех файлах вальта,
        если нашли — удалить (rehydrate-cleanup)
    -> snapshot(file_path=None, in_file=False)

если S нет (новая задача ИЛИ state потерян):
    found = искать taskbot:T.id в файлах всех проектов
    если found is None: append в target_file (по-настоящему новая)
    иначе если found == target_rel: replace в нём (текст/статус мог измениться)
    иначе: delete из found + append в target_file (проект сменился)
    -> snapshot(file_path=..., in_file=True)

если fingerprint(T) == fingerprint(S): no-op
если S.in_file == False (был cancelled, теперь восстановили): append
если T.project != S.project: delete из старого + append в новый
иначе: replace в S.file_path
```

**Важное отличие от спека:** ветка «S нет» расширена, чтобы корректно обрабатывать потерю `state.json`. Спек этот сценарий не описывал, но без поддержки получаются дубли строк при первом запуске после `rm state.json`.

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

`tests/mac/test_sync.py`:
```python
from pathlib import Path
import pytest
from tasks_bot.mac.config import PROJECT_TO_VAULT_PATH
from tasks_bot.mac.tasks_json_reader import RawTask
from tasks_bot.mac.state import SyncState, TaskSnapshot, snapshot_from
from tasks_bot.mac.sync import apply_one

def _raw(**kw):
    base = dict(
        id=1, text="x", project="life",
        deadline=None, deadline_kind="none", status="confirmed",
        created_at="2026-05-02T10:00:00+05:00",
        updated_at="2026-05-02T10:00:00+05:00",
        confirmed_at="2026-05-02T10:00:00+05:00",
        done_at=None,
    )
    base.update(kw); return RawTask(**base)

def test_new_task_appends_and_updates_state(tmp_path):
    raw = _raw(id=47, text="купить корм", project="life")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)
    f = tmp_path / "projects" / "life" / "tasks.md"
    assert f.read_text() == "- [ ] купить корм <!-- taskbot:47 -->\n"
    assert new_state.in_file is True
    assert new_state.file_path == "projects/life/tasks.md"

def test_unchanged_task_no_file_write(tmp_path):
    raw = _raw(id=47, text="купить корм", project="life")
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True); f.write_text("- [ ] купить корм <!-- taskbot:47 -->\n")
    prior = snapshot_from(raw, file_path="projects/life/tasks.md", in_file=True)

    mtime_before = f.stat().st_mtime_ns
    new_state = apply_one(raw, prior=prior, vault_root=tmp_path)
    assert f.stat().st_mtime_ns == mtime_before  # файл не трогали
    assert new_state == prior

def test_status_change_replaces_line(tmp_path):
    raw_done = _raw(id=47, text="купить корм", project="life",
                     deadline="2026-04-29", deadline_kind="date",
                     status="done", done_at="2026-04-29T14:30:00+05:00")
    raw_was_open = _raw(id=47, text="купить корм", project="life",
                          deadline="2026-04-29", deadline_kind="date")
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] купить корм 📅 2026-04-29 <!-- taskbot:47 -->\n")
    prior = snapshot_from(raw_was_open, file_path="projects/life/tasks.md", in_file=True)

    new_state = apply_one(raw_done, prior=prior, vault_root=tmp_path)
    assert f.read_text() == "- [x] купить корм 📅 2026-04-29 ✅ 2026-04-29 <!-- taskbot:47 -->\n"
    assert new_state.status == "done"

def test_cancelled_with_prior_in_file_deletes(tmp_path):
    raw_cancelled = _raw(id=47, status="cancelled")
    raw_was_active = _raw(id=47)
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] x <!-- taskbot:47 -->\n- [ ] keep <!-- taskbot:50 -->\n")
    prior = snapshot_from(raw_was_active, file_path="projects/life/tasks.md", in_file=True)

    new_state = apply_one(raw_cancelled, prior=prior, vault_root=tmp_path)
    assert f.read_text() == "- [ ] keep <!-- taskbot:50 -->\n"
    assert new_state.in_file is False
    assert new_state.file_path is None

def test_cancelled_without_prior_is_noop(tmp_path):
    """Задача появилась уже cancelled (в JSON есть, у нас в стейте нет) — ничего не делать."""
    raw = _raw(id=47, status="cancelled")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)
    assert not (tmp_path / "projects").exists()  # ничего не создали
    assert new_state.in_file is False

def test_restored_from_cancelled_appends(tmp_path):
    """Раньше была cancelled (in_file=False), теперь снова confirmed — дописать."""
    raw_active = _raw(id=47, text="вернули", project="life")
    raw_was_cancelled = _raw(id=47, status="cancelled")
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True); f.write_text("- [ ] keep <!-- taskbot:50 -->\n")
    prior = snapshot_from(raw_was_cancelled, file_path=None, in_file=False)

    new_state = apply_one(raw_active, prior=prior, vault_root=tmp_path)
    assert f.read_text() == (
        "- [ ] keep <!-- taskbot:50 -->\n"
        "- [ ] вернули <!-- taskbot:47 -->\n"
    )
    assert new_state.in_file is True

def test_project_change_moves_line(tmp_path):
    raw_now = _raw(id=47, text="t", project="mars")
    raw_was = _raw(id=47, text="t", project="life")
    fl = tmp_path / "projects" / "life" / "tasks.md"
    fl.parent.mkdir(parents=True)
    fl.write_text("- [ ] t <!-- taskbot:47 -->\n- [ ] keep <!-- taskbot:50 -->\n")
    prior = snapshot_from(raw_was, file_path="projects/life/tasks.md", in_file=True)

    new_state = apply_one(raw_now, prior=prior, vault_root=tmp_path)
    assert fl.read_text() == "- [ ] keep <!-- taskbot:50 -->\n"
    fm = tmp_path / "projects" / "mars" / "tasks.md"
    assert fm.read_text() == "- [ ] t <!-- taskbot:47 -->\n"
    assert new_state.file_path == "projects/mars/tasks.md"

def test_unknown_project_falls_back_to_inbox(tmp_path):
    """Если в JSON приехал project, которого нет в маппинге — кладём в _inbox.
    Это защитная мера — не должно быть, но если случилось, не падать."""
    raw = _raw(id=47, project="zzz_unknown")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)
    f = tmp_path / "_inbox" / "tasks.md"
    assert f.exists()
    assert new_state.file_path == "_inbox/tasks.md"

# --- Rehydrate-сценарии: state.json потерян, но файлы вальта есть ---

def test_no_prior_but_line_exists_in_target_file_replaces_not_dups(tmp_path):
    """state потерян, строка уже есть в правильном файле — обновить, не дублировать."""
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] старый текст <!-- taskbot:47 -->\n- [ ] другая <!-- taskbot:50 -->\n")

    raw = _raw(id=47, text="новый текст", project="life")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)

    assert f.read_text() == (
        "- [ ] новый текст <!-- taskbot:47 -->\n"
        "- [ ] другая <!-- taskbot:50 -->\n"
    )
    assert new_state.in_file is True
    assert new_state.file_path == "projects/life/tasks.md"

def test_no_prior_but_line_exists_in_other_project_moves(tmp_path):
    """state потерян, строка лежит в старом проекте, JSON говорит новый — перенести."""
    fl = tmp_path / "projects" / "life" / "tasks.md"
    fl.parent.mkdir(parents=True)
    fl.write_text("- [ ] переехала <!-- taskbot:47 -->\n- [ ] keep <!-- taskbot:50 -->\n")

    raw = _raw(id=47, text="переехала", project="mars")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)

    assert fl.read_text() == "- [ ] keep <!-- taskbot:50 -->\n"
    fm = tmp_path / "projects" / "mars" / "tasks.md"
    assert fm.read_text() == "- [ ] переехала <!-- taskbot:47 -->\n"
    assert new_state.file_path == "projects/mars/tasks.md"

def test_cancelled_without_prior_but_line_exists_deletes(tmp_path):
    """Cancelled пришёл, prior нет (state потерян), но в файле строка есть → стереть."""
    f = tmp_path / "projects" / "life" / "tasks.md"
    f.parent.mkdir(parents=True)
    f.write_text("- [ ] зомби <!-- taskbot:47 -->\n- [ ] keep <!-- taskbot:50 -->\n")

    raw = _raw(id=47, status="cancelled", project="life")
    new_state = apply_one(raw, prior=None, vault_root=tmp_path)

    assert f.read_text() == "- [ ] keep <!-- taskbot:50 -->\n"
    assert new_state.in_file is False
    assert new_state.file_path is None
```

- [ ] **Шаг 2: Падают (нет модуля)**

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

`src/tasks_bot/mac/sync.py`:
```python
from pathlib import Path
from typing import Optional
from tasks_bot.mac.config import PROJECT_TO_VAULT_PATH
from tasks_bot.mac.render_raw import render_line_for_raw
from tasks_bot.mac.state import TaskSnapshot, snapshot_from
from tasks_bot.mac.tasks_json_reader import RawTask
from tasks_bot.mac.vault_files import (
    append_line, replace_line_by_id, delete_line_by_id, find_existing_file_for_id,
    LineNotFound,
)

INBOX_REL = PROJECT_TO_VAULT_PATH["_inbox"]
ALL_VAULT_PATHS: list[str] = list(PROJECT_TO_VAULT_PATH.values())

def _vault_rel_path(project: str) -> str:
    return PROJECT_TO_VAULT_PATH.get(project, INBOX_REL)

def apply_one(raw: RawTask, prior: Optional[TaskSnapshot], vault_root: Path) -> TaskSnapshot:
    """Применить одну задачу к вальту. Вернуть обновлённый TaskSnapshot."""
    target_rel = _vault_rel_path(raw.project)
    target_abs = vault_root / target_rel

    # 1. Cancelled
    if raw.status == "cancelled":
        if prior and prior.in_file and prior.file_path:
            delete_line_by_id(vault_root / prior.file_path, raw.id)
        elif prior is None:
            # state.json потерян — строка может болтаться в файле, надо стереть
            found = find_existing_file_for_id(raw.id, vault_root, ALL_VAULT_PATHS)
            if found:
                delete_line_by_id(vault_root / found, raw.id)
        return snapshot_from(raw, file_path=None, in_file=False)

    new_line = render_line_for_raw(raw)

    # 2. Нет prior — может быть «правда новая» или «state потерян, строка уже в файле»
    if prior is None:
        existing_rel = find_existing_file_for_id(raw.id, vault_root, ALL_VAULT_PATHS)
        if existing_rel is None:
            # Действительно новая
            append_line(target_abs, new_line)
            return snapshot_from(raw, file_path=target_rel, in_file=True)
        if existing_rel == target_rel:
            # Тот же файл — обновить строку (могли поменяться текст/статус/срок)
            replace_line_by_id(target_abs, raw.id, new_line)
            return snapshot_from(raw, file_path=target_rel, in_file=True)
        # Лежит в другом файле — перенести
        delete_line_by_id(vault_root / existing_rel, raw.id)
        append_line(target_abs, new_line)
        return snapshot_from(raw, file_path=target_rel, in_file=True)

    # 3. Не изменилась
    if snapshot_from(raw, file_path=prior.file_path, in_file=prior.in_file).fingerprint() == prior.fingerprint():
        return prior

    # 4. Восстановили из cancelled
    if not prior.in_file:
        append_line(target_abs, new_line)
        return snapshot_from(raw, file_path=target_rel, in_file=True)

    # 5. Сменился проект
    if raw.project != prior.project and prior.file_path:
        delete_line_by_id(vault_root / prior.file_path, raw.id)
        append_line(target_abs, new_line)
        return snapshot_from(raw, file_path=target_rel, in_file=True)

    # 6. Просто переписать строку в том же файле
    if prior.file_path:
        try:
            replace_line_by_id(vault_root / prior.file_path, raw.id, new_line)
        except LineNotFound:
            # Стейт говорит что строка есть, а её нет (пользователь руками удалил).
            # Допишем заново.
            append_line(vault_root / prior.file_path, new_line)
        return snapshot_from(raw, file_path=prior.file_path, in_file=True)

    # Защита: prior.in_file=True но file_path=None — рассогласованный стейт, дописываем
    append_line(target_abs, new_line)
    return snapshot_from(raw, file_path=target_rel, in_file=True)
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_sync.py -v
```

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

```bash
git commit -am "tasks-bot/mac: apply_one with all branches of sync algorithm"
```

---

## Задача E.4: `apply_all` — драйвер для всех задач

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

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

В `tests/mac/test_sync.py` добавить:
```python
from tasks_bot.mac.sync import apply_all

def test_apply_all_processes_each_and_returns_new_state(tmp_path):
    raws = [
        _raw(id=1, text="первая", project="life"),
        _raw(id=2, text="вторая", project="mars"),
        _raw(id=3, status="cancelled"),  # cancelled без prior — no-op
    ]
    new_state = apply_all(raws, prior_state=SyncState(tasks={}), vault_root=tmp_path)
    assert "1" in new_state.tasks
    assert "2" in new_state.tasks
    assert "3" in new_state.tasks
    assert new_state.tasks["1"].in_file is True
    assert new_state.tasks["3"].in_file is False
    assert (tmp_path / "projects" / "life" / "tasks.md").exists()
    assert (tmp_path / "projects" / "mars" / "tasks.md").exists()

def test_apply_all_idempotent(tmp_path):
    raws = [_raw(id=1, text="t", project="life")]
    s1 = apply_all(raws, SyncState(tasks={}), tmp_path)
    f = tmp_path / "projects" / "life" / "tasks.md"
    mtime = f.stat().st_mtime_ns
    s2 = apply_all(raws, s1, tmp_path)
    assert f.stat().st_mtime_ns == mtime  # второй раз ничего не пишем
    assert s1 == s2
```

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

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

В `src/tasks_bot/mac/sync.py` добавить:
```python
from tasks_bot.mac.state import SyncState

def apply_all(raws: list[RawTask], prior_state: SyncState, vault_root: Path) -> SyncState:
    """Применить весь tasks.json. Вернуть новый SyncState."""
    new_tasks: dict[str, TaskSnapshot] = {}
    for raw in raws:
        prior = prior_state.tasks.get(str(raw.id))
        new_tasks[str(raw.id)] = apply_one(raw, prior, vault_root)
    return SyncState(tasks=new_tasks)
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_sync.py -v
```

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

```bash
git commit -am "tasks-bot/mac: apply_all driver iterates raws and rebuilds SyncState"
```

---

## Задача E.5: Тест на «золотых» фикстурах из Плана 1

**Files:**
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_sync_fixtures.py`

Если фаза C.4 Плана 1 ещё не выполнена — фикстуры можно сгенерировать по приложению в конце этого плана.

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

`tests/mac/test_sync_fixtures.py`:
```python
import json
from pathlib import Path
import pytest
from tasks_bot.mac.tasks_json_reader import RawTask
from tasks_bot.mac.state import SyncState
from tasks_bot.mac.sync import apply_all

FIXTURES = Path(__file__).parents[1] / "fixtures" / "tasks_json"

@pytest.mark.parametrize("name", ["empty", "single_active", "with_done_and_cancelled", "multiple_projects"])
def test_apply_all_on_fixture_runs_clean(name, tmp_path):
    p = FIXTURES / f"{name}.json"
    if not p.exists():
        pytest.skip(f"fixture {p} missing — generate via Plan 1 task C.4")
    raws = [RawTask.model_validate(item) for item in json.loads(p.read_text())]
    state = apply_all(raws, SyncState(tasks={}), tmp_path)

    # Проверка: для каждой не-cancelled задачи в JSON должна быть строка в файле
    for raw in raws:
        if raw.status == "cancelled":
            continue
        snap = state.tasks[str(raw.id)]
        assert snap.in_file is True
        assert snap.file_path is not None
        f = tmp_path / snap.file_path
        assert f.exists()
        text = f.read_text()
        assert f"taskbot:{raw.id}" in text

@pytest.mark.parametrize("name", ["single_active", "with_done_and_cancelled", "multiple_projects"])
def test_apply_all_idempotent_on_fixtures(name, tmp_path):
    p = FIXTURES / f"{name}.json"
    if not p.exists():
        pytest.skip(f"fixture {p} missing")
    raws = [RawTask.model_validate(item) for item in json.loads(p.read_text())]
    s1 = apply_all(raws, SyncState(tasks={}), tmp_path)
    snapshots = {f: f.stat().st_mtime_ns for f in tmp_path.rglob("*.md")}
    s2 = apply_all(raws, s1, tmp_path)
    for f, mtime in snapshots.items():
        assert f.stat().st_mtime_ns == mtime
    assert s1 == s2
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_sync_fixtures.py -v
```

Если фикстуры есть — все тесты проходят. Если нет — тесты `skip`, не fail. Это нормально, План 2 разрабатывается независимо.

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

```bash
git commit -am "tasks-bot/mac: integration tests on Plan 1 golden fixtures"
```

---

# Фаза F — CLI и end-to-end

## Задача F.1: `cli.py` — точка входа

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

CLI делает: lock → pull → read tasks.json → load state → apply_all → save state.

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

`tests/mac/test_cli.py`:
```python
import asyncio
import json
import subprocess
from pathlib import Path
import pytest
from tasks_bot.mac.cli import run_async

@pytest.fixture
def setup_env(tmp_path, monkeypatch):
    """Создаёт remote+clone+vault, отдаёт пути для прогона CLI."""
    remote = tmp_path / "remote.git"
    seed = tmp_path / "seed"
    repo = tmp_path / "repo"
    vault = tmp_path / "vault"
    state_path = tmp_path / "state.json"

    subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], check=True)
    subprocess.run(["git", "clone", str(remote), str(seed)], check=True)
    (seed / "tasks.json").write_text("[]")
    subprocess.run(["git", "-C", str(seed), "add", "."], check=True)
    subprocess.run(["git", "-C", str(seed), "-c", "user.email=t@t", "-c", "user.name=t",
                     "commit", "-m", "init"], check=True)
    subprocess.run(["git", "-C", str(seed), "push", "origin", "main"], check=True)
    subprocess.run(["git", "clone", str(remote), str(repo)], check=True)

    vault.mkdir()
    monkeypatch.setenv("TASKS_BOT_REPO_PATH", str(repo))
    monkeypatch.setenv("TASKS_BOT_VAULT_PATH", str(vault))
    monkeypatch.setenv("TASKS_BOT_STATE_PATH", str(state_path))
    from tasks_bot.mac.config import get_mac_settings
    get_mac_settings.cache_clear()

    return {"remote": remote, "seed": seed, "repo": repo, "vault": vault, "state": state_path}

def _push_tasks_json(seed_repo: Path, payload: list[dict]):
    (seed_repo / "tasks.json").write_text(json.dumps(payload))
    subprocess.run(["git", "-C", str(seed_repo), "commit", "-am", "update"], check=True)
    subprocess.run(["git", "-C", str(seed_repo), "push", "origin", "main"], check=True)

@pytest.mark.asyncio
async def test_cli_creates_files_for_new_tasks(setup_env):
    e = setup_env
    _push_tasks_json(e["seed"], [{
        "id": 47, "text": "позвонить Ане", "project": "mars",
        "deadline": "2026-05-03", "deadline_kind": "date", "status": "confirmed",
        "created_at": "2026-05-02T10:00:00+05:00",
        "updated_at": "2026-05-02T10:00:00+05:00",
        "confirmed_at": "2026-05-02T10:00:00+05:00",
        "done_at": None,
    }])

    await run_async()

    f = e["vault"] / "projects" / "mars" / "tasks.md"
    assert "позвонить Ане" in f.read_text()
    assert "taskbot:47" in f.read_text()
    state = json.loads(e["state"].read_text())
    assert "47" in state["tasks"]

@pytest.mark.asyncio
async def test_cli_idempotent_on_repeat(setup_env):
    e = setup_env
    _push_tasks_json(e["seed"], [{
        "id": 47, "text": "x", "project": "life",
        "deadline": None, "deadline_kind": "none", "status": "confirmed",
        "created_at": "2026-05-02T10:00:00+05:00",
        "updated_at": "2026-05-02T10:00:00+05:00",
        "confirmed_at": "2026-05-02T10:00:00+05:00",
        "done_at": None,
    }])
    await run_async()
    f = e["vault"] / "projects" / "life" / "tasks.md"
    mtime = f.stat().st_mtime_ns
    await run_async()
    assert f.stat().st_mtime_ns == mtime  # второй прогон — нет записей

@pytest.mark.asyncio
async def test_cli_survives_offline_pull(setup_env):
    """Если pull упал — продолжаем со старой локальной копией tasks.json."""
    e = setup_env
    # Сразу есть пустой tasks.json (clone уже был). Сделаем remote недоступным:
    subprocess.run(["rm", "-rf", str(e["remote"])], check=True)

    # Не должно упасть — pull неудачен, обработали локальный пустой JSON
    await run_async()
    state = json.loads(e["state"].read_text())
    assert state == {"tasks": {}}
```

- [ ] **Шаг 2: Падают (нет модуля)**

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

`src/tasks_bot/mac/cli.py`:
```python
import asyncio
import logging
import sys
from tasks_bot.mac.config import get_mac_settings
from tasks_bot.mac.git_pull import try_git_pull
from tasks_bot.mac.lockfile import sync_lock, LockBusy
from tasks_bot.mac.state import load_state, save_state
from tasks_bot.mac.sync import apply_all
from tasks_bot.mac.tasks_json_reader import read_tasks_json

log = logging.getLogger("tasks_bot.mac")

async def run_async() -> int:
    """Один проход синка. Возвращает код выхода."""
    s = get_mac_settings()
    lock_path = s.state_path.parent / "sync.lock"
    try:
        with sync_lock(lock_path):
            await try_git_pull(s.repo_path)
            raws = read_tasks_json(s.repo_path / "tasks.json")
            prior = load_state(s.state_path)
            new_state = apply_all(raws, prior, s.vault_path)
            save_state(s.state_path, new_state)
            log.info("sync ok: %d tasks processed", len(raws))
            return 0
    except LockBusy:
        log.info("another sync is running, skipping")
        return 0
    except Exception:
        log.exception("sync failed")
        return 1

def run() -> None:
    """Console-script entry point."""
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(name)s %(levelname)s: %(message)s",
    )
    sys.exit(asyncio.run(run_async()))
```

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_cli.py -v
```

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

```bash
git commit -am "tasks-bot/mac: cli orchestrates lock + pull + apply + state save"
```

---

## Задача F.2: Логирование в файл

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

launchd по умолчанию глотает stdout/stderr (или пишет туда, куда никто не смотрит). Добавим RotatingFileHandler в `~/.tasks-bot-sync/sync.log`, чтобы можно было `tail -f`.

- [ ] **Шаг 1: Поправить `run()`**

В `src/tasks_bot/mac/cli.py` заменить тело `run()`:
```python
def run() -> None:
    from logging.handlers import RotatingFileHandler
    s = get_mac_settings()
    log_path = s.state_path.parent / "sync.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)

    handler = RotatingFileHandler(log_path, maxBytes=512_000, backupCount=3, encoding="utf-8")
    handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
    root = logging.getLogger()
    root.setLevel(logging.INFO)
    root.addHandler(handler)
    root.addHandler(logging.StreamHandler())  # дублируем в stderr — launchd может пишет в свой лог

    sys.exit(asyncio.run(run_async()))
```

- [ ] **Шаг 2: Smoke-тест что лог пишется**

```bash
cd projects/vibe-coding/tasks-bot && \
  TASKS_BOT_REPO_PATH=/tmp/none TASKS_BOT_VAULT_PATH=/tmp/v TASKS_BOT_STATE_PATH=/tmp/state.json \
  python -c "from tasks_bot.mac.cli import run; run()" || true
ls -la /tmp/sync.log 2>/dev/null || echo "лог в state_path.parent"
```

(точная проверка лога — в реальном деплое; здесь главное, что не падает)

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

```bash
git commit -am "tasks-bot/mac: rotating file logger at state_path.parent/sync.log"
```

---

## Задача F.3: Smoke-запуск с настоящим GitHub-репо, но фейковым вальтом

Этот шаг **требует, чтобы внешние артефакты были готовы**: создан приватный репо `tasks-bot-data`, в него руками положили `tasks.json` хотя бы с одной фейковой задачей.

**Безопасность.** Smoke использует **временный каталог как vault**, чтобы случайно не дописать тестовую строку в реальный `_inbox/tasks.md` (там могут быть твои настоящие записи). Реальный путь к вальту проверим отдельным шагом — просто посмотрим, что переменная окружения смотрит куда надо, без записи.

- [ ] **Шаг 1: Подготовить локальное окружение**

```bash
# Создать локальный клон репозитория
mkdir -p ~/tasks-bot-data
git clone git@github.com:<твой_логин>/tasks-bot-data.git ~/tasks-bot-data

# Если репо ещё пустой — создать tasks.json с одной задачей и запушить
cd ~/tasks-bot-data
echo '[{"id":1,"text":"тестовая","project":"_inbox","deadline":null,"deadline_kind":"none","status":"confirmed","created_at":"2026-05-02T10:00:00+05:00","updated_at":"2026-05-02T10:00:00+05:00","confirmed_at":"2026-05-02T10:00:00+05:00","done_at":null}]' > tasks.json
git add tasks.json && git commit -m "seed" && git push
```

- [ ] **Шаг 2: Создать временный вальт и состояние**

```bash
SMOKE_VAULT=$(mktemp -d -t tasks-bot-smoke-vault)
SMOKE_STATE=$(mktemp -d -t tasks-bot-smoke-state)/state.json
echo "SMOKE_VAULT=$SMOKE_VAULT"
echo "SMOKE_STATE=$SMOKE_STATE"
```

(переменные используем в следующих шагах)

- [ ] **Шаг 3: Запустить sync вручную**

```bash
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/tasks-bot && \
  TASKS_BOT_REPO_PATH=$HOME/tasks-bot-data \
  TASKS_BOT_VAULT_PATH=$SMOKE_VAULT \
  TASKS_BOT_STATE_PATH=$SMOKE_STATE \
  tasks-mac-sync
```

Expected:
- В `$SMOKE_VAULT/_inbox/tasks.md` появляется `- [ ] тестовая <!-- taskbot:1 -->`
- В `$SMOKE_STATE` — объект задачи с `in_file: true`
- В `$(dirname $SMOKE_STATE)/sync.log` — строка `sync ok: 1 tasks processed`

Проверить:
```bash
cat $SMOKE_VAULT/_inbox/tasks.md
cat $SMOKE_STATE
cat $(dirname $SMOKE_STATE)/sync.log
```

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

```bash
MTIME_BEFORE=$(stat -f "%m" $SMOKE_VAULT/_inbox/tasks.md)
TASKS_BOT_REPO_PATH=$HOME/tasks-bot-data \
TASKS_BOT_VAULT_PATH=$SMOKE_VAULT \
TASKS_BOT_STATE_PATH=$SMOKE_STATE \
tasks-mac-sync
MTIME_AFTER=$(stat -f "%m" $SMOKE_VAULT/_inbox/tasks.md)
[ "$MTIME_BEFORE" = "$MTIME_AFTER" ] && echo "OK: файл не менялся" || echo "FAIL: файл переписали"
```

Должно вывести «OK: файл не менялся».

- [ ] **Шаг 5: Проверить rehydrate — удалить state, оставить файл, запустить ещё раз**

```bash
rm $SMOKE_STATE
TASKS_BOT_REPO_PATH=$HOME/tasks-bot-data \
TASKS_BOT_VAULT_PATH=$SMOKE_VAULT \
TASKS_BOT_STATE_PATH=$SMOKE_STATE \
tasks-mac-sync

# Проверка: строки таскбота 1 в файле должно быть ровно одно вхождение, не два
grep -c "taskbot:1" $SMOKE_VAULT/_inbox/tasks.md
# Должно вывести: 1
```

Если показало 2 — баг в rehydrate, не двигаемся к Фазе G.

- [ ] **Шаг 6: Снести временные каталоги**

```bash
rm -rf $SMOKE_VAULT $(dirname $SMOKE_STATE)
```

- [ ] **Шаг 7: Только посмотреть на боевые пути (без записи)**

```bash
echo "Боевой VAULT_PATH: /Users/nataliadudina/Desktop/ObsidianVault"
ls -d /Users/nataliadudina/Desktop/ObsidianVault   # должен существовать
echo "Боевой STATE_PATH: $HOME/.tasks-bot-sync/state.json"
ls -d $HOME/.tasks-bot-sync 2>/dev/null || echo "(каталог создаст сервис при первом запуске)"
```

Никаких записей пока — реальный запуск под боевыми путями делает уже launchd в Фазе G.

- [ ] **Шаг 8: Коммит документации**

В этом шаге изменений в коде нет — пропустить коммит.

---

# Фаза G — launchd

## Задача G.1: launchd plist + генератор

**Files:**
- Create: `projects/vibe-coding/tasks-bot/deploy/ru.dudina.tasks-bot.mac-sync.plist`
- Create: `projects/vibe-coding/tasks-bot/src/tasks_bot/mac/plist_template.py`
- Create: `projects/vibe-coding/tasks-bot/tests/mac/test_plist_template.py`

Шаблон содержит плейсхолдеры `__BIN_PATH__`, `__REPO_PATH__`, `__VAULT_PATH__`, `__STATE_PATH__` — генератор подставит реальные пути.

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

`tests/mac/test_plist_template.py`:
```python
from pathlib import Path
from tasks_bot.mac.plist_template import build_plist, PLIST_TEMPLATE

def test_template_has_all_placeholders():
    for placeholder in ["__BIN_PATH__", "__REPO_PATH__", "__VAULT_PATH__", "__STATE_PATH__"]:
        assert placeholder in PLIST_TEMPLATE

def test_build_substitutes_paths():
    out = build_plist(
        bin_path=Path("/u/n/.venv/bin/tasks-mac-sync"),
        repo_path=Path("/u/n/tasks-bot-data"),
        vault_path=Path("/u/n/Vault"),
        state_path=Path("/u/n/.tasks-bot-sync/state.json"),
    )
    assert "/u/n/.venv/bin/tasks-mac-sync" in out
    assert "/u/n/tasks-bot-data" in out
    assert "/u/n/Vault" in out
    assert "/u/n/.tasks-bot-sync/state.json" in out
    assert "__BIN_PATH__" not in out  # все подставлено
```

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

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

`src/tasks_bot/mac/plist_template.py`:
```python
from pathlib import Path

PLIST_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>ru.dudina.tasks-bot.mac-sync</string>

    <key>ProgramArguments</key>
    <array>
        <string>__BIN_PATH__</string>
    </array>

    <key>EnvironmentVariables</key>
    <dict>
        <key>TASKS_BOT_REPO_PATH</key>
        <string>__REPO_PATH__</string>
        <key>TASKS_BOT_VAULT_PATH</key>
        <string>__VAULT_PATH__</string>
        <key>TASKS_BOT_STATE_PATH</key>
        <string>__STATE_PATH__</string>
        <key>PATH</key>
        <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
    </dict>

    <key>StartInterval</key>
    <integer>300</integer>

    <key>RunAtLoad</key>
    <true/>

    <key>StandardOutPath</key>
    <string>__STATE_PATH_DIR__/launchd.out.log</string>
    <key>StandardErrorPath</key>
    <string>__STATE_PATH_DIR__/launchd.err.log</string>
</dict>
</plist>
"""

def build_plist(bin_path: Path, repo_path: Path, vault_path: Path, state_path: Path) -> str:
    return (
        PLIST_TEMPLATE
        .replace("__BIN_PATH__", str(bin_path))
        .replace("__REPO_PATH__", str(repo_path))
        .replace("__VAULT_PATH__", str(vault_path))
        .replace("__STATE_PATH__", str(state_path))
        .replace("__STATE_PATH_DIR__", str(state_path.parent))
    )
```

Файл `deploy/ru.dudina.tasks-bot.mac-sync.plist` — это уже **сгенерированная** версия с настоящими путями Наташи. Создать командой:

```bash
cd projects/vibe-coding/tasks-bot && python -c "
from pathlib import Path
from tasks_bot.mac.plist_template import build_plist
out = build_plist(
    bin_path=Path.home() / '.venvs' / 'tasks-bot' / 'bin' / 'tasks-mac-sync',
    repo_path=Path.home() / 'tasks-bot-data',
    vault_path=Path('/Users/nataliadudina/Desktop/ObsidianVault'),
    state_path=Path.home() / '.tasks-bot-sync' / 'state.json',
)
Path('deploy/ru.dudina.tasks-bot.mac-sync.plist').write_text(out)
"
```

(если venv лежит в другом месте — поправить `bin_path`)

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

```bash
cd projects/vibe-coding/tasks-bot && pytest tests/mac/test_plist_template.py -v
```

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

```bash
git add deploy/ru.dudina.tasks-bot.mac-sync.plist \
         src/tasks_bot/mac/plist_template.py \
         tests/mac/test_plist_template.py
git commit -m "tasks-bot/mac: launchd plist template + generated deploy artifact"
```

---

## Задача G.2: Установка через `launchctl` и проверка

**Files:**
- Create: `projects/vibe-coding/tasks-bot/deploy/install-mac.md`

- [ ] **Шаг 1: Записать инструкцию**

`deploy/install-mac.md`:
```markdown
# Установка mac-sync

## Требования
- Python 3.11+ установлен (`brew install python@3.11` или pyenv)
- Создан и доступен по SSH приватный GitHub-репо `tasks-bot-data`
- Локально склонирован в `~/tasks-bot-data`
- В `~/tasks-bot-data` есть хотя бы пустой `tasks.json` (`echo "[]" > tasks.json && git add . && git commit -m init && git push`)

## Установка
```bash
# 1. Создать venv
python3.11 -m venv ~/.venvs/tasks-bot
source ~/.venvs/tasks-bot/bin/activate

# 2. Поставить пакет в venv
cd /Users/nataliadudina/Desktop/ObsidianVault/projects/vibe-coding/tasks-bot
pip install -e .

# 3. Проверить, что бинарь в PATH
which tasks-mac-sync
# Должно показать: /Users/nataliadudina/.venvs/tasks-bot/bin/tasks-mac-sync

# 4. Запустить вручную один раз
TASKS_BOT_REPO_PATH=$HOME/tasks-bot-data \
TASKS_BOT_VAULT_PATH=/Users/nataliadudina/Desktop/ObsidianVault \
TASKS_BOT_STATE_PATH=$HOME/.tasks-bot-sync/state.json \
tasks-mac-sync

# 5. Проверить лог
cat ~/.tasks-bot-sync/sync.log
```

## Установка launchd-сервиса

```bash
# Скопировать plist в LaunchAgents
cp deploy/ru.dudina.tasks-bot.mac-sync.plist ~/Library/LaunchAgents/

# Загрузить в launchd
launchctl load -w ~/Library/LaunchAgents/ru.dudina.tasks-bot.mac-sync.plist

# Убедиться, что сервис стартовал
launchctl list | grep tasks-bot
# должно показать строку с pid и кодом 0

# Через 5 минут проверить, что лог обновляется
tail -f ~/.tasks-bot-sync/sync.log
```

## Управление

```bash
# Остановить
launchctl unload ~/Library/LaunchAgents/ru.dudina.tasks-bot.mac-sync.plist

# Снова запустить
launchctl load -w ~/Library/LaunchAgents/ru.dudina.tasks-bot.mac-sync.plist

# Запустить прямо сейчас, не дожидаясь интервала
launchctl start ru.dudina.tasks-bot.mac-sync
```

## Если что-то сломалось

- `~/.tasks-bot-sync/sync.log` — основной лог приложения
- `~/.tasks-bot-sync/launchd.err.log` — stderr launchd (тут ошибки старта)
- `~/.tasks-bot-sync/launchd.out.log` — stdout launchd
- Удалить state и начать с чистого листа:
  ```bash
  rm ~/.tasks-bot-sync/state.json
  launchctl start ru.dudina.tasks-bot.mac-sync
  ```
```

- [ ] **Шаг 2: Установить и запустить (вручную, в реальной системе)**

Пройти инструкцию выше шаг за шагом. Проверить:
1. После `tasks-mac-sync` вручную — в вальте появились файлы tasks.md
2. После `launchctl load` — `launchctl list | grep tasks-bot` показывает pid
3. Через 5 минут — `tail ~/.tasks-bot-sync/sync.log` показывает второй запуск с `sync ok`

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

```bash
git add deploy/install-mac.md
git commit -m "tasks-bot/mac: install instructions for launchd"
```

---

# Готово

После Фазы G mac-sync работает в фоне:
- Раз в 5 минут пуллит `tasks.json` из приватного GitHub-репо
- Применяет изменения к `projects/{mars,channel,consulting,life}/tasks.md` и `_inbox/tasks.md`
- Идемпотентно: повторный запуск без изменений в JSON не пишет ни в один файл
- Устойчиво к offline: если pull упал — обработает локальную копию JSON и не уронит сервис

**Готовность к Плану 3 (сводки утро/вечер/воскресенье + команды).** План 3 живёт целиком на Pi, не трогает Mac. Mac будет автоматически подхватывать изменения статусов через тот же `apply_one`.

---

# Приложение: фикстуры `tasks.json` (если План 1 ещё не выполнен)

Если фикстуры `tests/fixtures/tasks_json/*.json` ещё не созданы (Задача C.4 Плана 1) — можно сгенерировать руками:

`tests/fixtures/tasks_json/empty.json`:
```json
[]
```

`tests/fixtures/tasks_json/single_active.json`:
```json
[
  {
    "id": 47,
    "text": "позвонить Ане",
    "project": "mars",
    "deadline": "2026-05-03",
    "deadline_kind": "date",
    "status": "confirmed",
    "created_at": "2026-05-02T10:00:00+05:00",
    "updated_at": "2026-05-02T10:00:00+05:00",
    "confirmed_at": "2026-05-02T10:00:00+05:00",
    "done_at": null
  }
]
```

`tests/fixtures/tasks_json/with_done_and_cancelled.json`:
```json
[
  {
    "id": 1,
    "text": "купить корм",
    "project": "life",
    "deadline": "2026-05-01",
    "deadline_kind": "date",
    "status": "done",
    "created_at": "2026-05-01T09:00:00+05:00",
    "updated_at": "2026-05-01T18:00:00+05:00",
    "confirmed_at": "2026-05-01T09:01:00+05:00",
    "done_at": "2026-05-01T18:00:00+05:00"
  },
  {
    "id": 2,
    "text": "идея для контент-плана",
    "project": "mars",
    "deadline": null,
    "deadline_kind": "none",
    "status": "confirmed",
    "created_at": "2026-05-01T11:00:00+05:00",
    "updated_at": "2026-05-01T11:01:00+05:00",
    "confirmed_at": "2026-05-01T11:01:00+05:00",
    "done_at": null
  },
  {
    "id": 3,
    "text": "отменённая",
    "project": "_inbox",
    "deadline": null,
    "deadline_kind": "none",
    "status": "cancelled",
    "created_at": "2026-04-30T08:00:00+05:00",
    "updated_at": "2026-04-30T20:00:00+05:00",
    "confirmed_at": "2026-04-30T08:01:00+05:00",
    "done_at": null
  }
]
```

`tests/fixtures/tasks_json/multiple_projects.json`:
```json
[
  {"id": 10, "text": "мars-задача",       "project": "mars",        "deadline": null, "deadline_kind": "none", "status": "confirmed", "created_at": "2026-05-02T10:00:00+05:00", "updated_at": "2026-05-02T10:00:00+05:00", "confirmed_at": "2026-05-02T10:00:00+05:00", "done_at": null},
  {"id": 11, "text": "channel-задача",   "project": "channel",     "deadline": null, "deadline_kind": "none", "status": "confirmed", "created_at": "2026-05-02T10:00:00+05:00", "updated_at": "2026-05-02T10:00:00+05:00", "confirmed_at": "2026-05-02T10:00:00+05:00", "done_at": null},
  {"id": 12, "text": "consulting-задача","project": "consulting",  "deadline": null, "deadline_kind": "none", "status": "confirmed", "created_at": "2026-05-02T10:00:00+05:00", "updated_at": "2026-05-02T10:00:00+05:00", "confirmed_at": "2026-05-02T10:00:00+05:00", "done_at": null},
  {"id": 13, "text": "life-задача",       "project": "life",        "deadline": null, "deadline_kind": "none", "status": "confirmed", "created_at": "2026-05-02T10:00:00+05:00", "updated_at": "2026-05-02T10:00:00+05:00", "confirmed_at": "2026-05-02T10:00:00+05:00", "done_at": null},
  {"id": 14, "text": "inbox-задача",     "project": "_inbox",      "deadline": null, "deadline_kind": "none", "status": "confirmed", "created_at": "2026-05-02T10:00:00+05:00", "updated_at": "2026-05-02T10:00:00+05:00", "confirmed_at": "2026-05-02T10:00:00+05:00", "done_at": null}
]
```
