Пакетная работа с доской Asana из JSON-плана: развернуть, досыпать задачи, закрыть, свести часы: "разверни доску", "посчитай часы за период". Лента комментариев - `asana-comments`.
Installs into .claude/skills of the current project.
Are you the author of Asana Project?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/dewil-asana-project)
---
name: asana-project
description: Пакетная работа с доской Asana из JSON-плана: развернуть, досыпать задачи, закрыть, свести часы: "разверни доску", "посчитай часы за период". Лента комментариев - `asana-comments`.
---
# asana-project
Пакетная запись в Asana из JSON-плана: `create` - проект с секциями и задачами с нуля, `tasks` - досыпка задач в существующий проект, `move` - перенос существующих задач в секцию, `complete` - пакетное закрытие, `summary` - сводка часов по секции (только чтение). Скрипт-эталон - `scripts/asana-project.py` (top-level папка `scripts/` в корне проекта). Только stdlib, из внешнего - опционально `certifi`.
## Зачем это поверх поштучных вызовов
Поштучный канал (что MCP, пока он использовался, что ручные REST-запросы) заводит задачи по одной, и у него нет ни идемпотентного повтора, ни синка сроков из плана-файла. Развернуть доску по итогам встречи (13 задач по трем секциям, у каждой исполнитель, описание, срок) - десятки шагов; через JSON-план - одна команда и одно подтверждение. Пробел не в "поштучно нельзя", а в **пакетности и повторяемости**: план приходит целиком, живет файлом под рукой, и повторный прогон того же файла безопасен.
## Границы
- **Единичные операции** (создать одну задачу, сменить статус, написать комментарий) - REST напрямую тем же PAT: база `https://app.asana.com/api/1.0`, заголовок `Authorization: Bearer <token>`; комментарий - `POST /tasks/{gid}/stories` с полем `html_text`. Понадобилось часто - завести маленький скрипт рядом, а не поднимать MCP (см. "Граница с Asana MCP" ниже).
- **Чтение длинной ленты комментариев** - скилл `asana-comments`: поштучный запрос задачи отдает ленту урезанной и молча.
- **Пакетное разворачивание, досыпка, пересмотр сроков, перенос в секцию, закрытие, сводка часов** - этот скилл.
- Скрипт намеренно НЕ делает: стирание срока (срок только добавляется и двигается), смену исполнителя существующей задачи, удаление задач, **снятие галки "выполнено"**. Массовое переоткрытие на общей доске опаснее, чем разовое снятие галки руками.
**Перенос и закрытие раньше были в этом списке - и это было ошибкой.** Формально они единичные операции, фактически - два шага одной регулярной процедуры (закрытие отчетного периода), и делались прямым REST в обход dry-run. То есть ровно те две операции, которые правят **чужие существующие** задачи, шли без предпросмотра, тогда как безобидное создание новых было им закрыто. Пробел был не в удобстве, а в гейте.
## Граница с Asana MCP
**MCP не используется: снят 17.08.2026, возвращать не надо.** За одну сессию три сбоя - дисконнекты, зависание вызова на 300 с, `Not Authorized` сразу после переавторизации, - при нуле сбоев у скриптов на PAT. Отдельно от надежности: OAuth молча берет аккаунт из того браузера, где залогинен человек, поэтому привязка не к тому аккаунту проходит незаметно (`rules/mcp-multi-account.md`), а в headless и cron протухший токен некому переавторизовать. Все, что раньше закрывал MCP, теперь закрывают PAT-скрипты и прямой REST.
## Жесткий гейт перед --send
Доска общая: созданные задачи сразу видит команда, а откатывать массовую заливку - руками удалять задачи. Поэтому:
1. **Всегда сначала dry-run** (дефолт, без `--send`). Он показывает построчно: секция (есть / будет создана), задача, исполнитель, срок, и что произойдет - `ПРОПУСК (уже есть, без изменений)` либо `СРОК: 2026-08-03 -> 2026-08-10`. У `tasks` dry-run и send решают по одной и той же функции (`plan_effect`) - предпросмотр не разойдется с действием; у `create` действие всегда одно (создать все перечисленное), там расходиться нечему.
2. **Чей токен и какой воркспейс - реши до `--send`.** Dry-run показывает содержимое, но не от чьего имени уйдет запись. Аккаунтов несколько (личный и заказчика отдельными файлами) - `--auth <файл>` обязателен в каждой команде: без него скрипт молча возьмет `ASANA_TOKEN` или дефолтный `~/.config/asana/auth.json` (`rules/mcp-multi-account.md`). Аккаунт один, но состоит сразу в нескольких организациях - файл не спасает, потому что различает не он: смотри `workspace` в плане у `create` (`workspaces` покажет gid каждой) и проект у `tasks`. Личная доска и клиентская отличаются одним числом в плане, и ошибка здесь видна только на доске заказчика.
3. **Показать вывод пользователю и получить явное "ок"** на этот конкретный вывод - только потом `--send`, сразу после "ок". Прошло заметное время или доску кто-то трогал - повтори dry-run: доска общая, состояние могло уехать, а send перечитывает ее заново.
4. **`complete` - необратимая операция**, и dry-run у нее единственная защита: он печатает поименно, что будет закрыто, и сколько задач уже закрыто. Писать он умеет только формой `{project, tasks: [...]}` - перечнем gid, который сам же и печатает; форма `{project, section}` с `--send` отклоняется, потому что состав секции между показом и записью может измениться (подробнее - в "Закрытии отчетного периода").
5. План со сроками - это еще и даты, которые увидят исполнители: проверь их на здравость (не в прошлом, не на выходных без причины).
## CLI
```bash
python3 scripts/asana-project.py workspaces # аккаунт и gid воркспейсов
python3 scripts/asana-project.py create --plan план.json # предпросмотр
python3 scripts/asana-project.py create --plan план.json --send # создать проект
python3 scripts/asana-project.py tasks --plan досыпка.json --send # досыпать в существующий
python3 scripts/asana-project.py tasks --plan план.json --auth ~/.config/asana/auth-work.json
python3 scripts/asana-project.py move --plan перенос.json --send # перенести в секцию
python3 scripts/asana-project.py summary --project 12345 --section "Отчет 01.03 - 31.08"
python3 scripts/asana-project.py complete --plan закрытие.json --send # НЕОБРАТИМО
```
В проекте с разведенными аккаунтами `--auth <файл>` добавляется к каждой из этих команд, включая dry-run (пункт 2 гейта).
Формат плана для `create` - `workspace` (gid из `workspaces`), `name`, `notes`, `sections`; для `tasks` вместо них один ключ `project` (gid доски - последнее число в ее URL):
```json
{
"project": "1216875635976802",
"sections": [
{"name": "Этап 0", "tasks": [
{"name": "Задача", "notes": "контекст", "assignee": "user@example.com", "due_on": "2026-08-03"}
]}
]
}
```
**Исполнитель задается почтой** - Asana принимает email в `assignee` наравне с gid, резолвить пользователей отдельным запросом не нужно; в плане человекочитаемые адреса. Опечатка в почте уронит прогон на середине. Для `tasks` это безопасно: повторный прогон доздаст недостающее без дублей. Для `create` - НЕТ: проект к этому моменту уже создан, повтор `create` создаст второй; скрипт при обрыве сам печатает в stderr gid проекта и напоминание доздать через `tasks` (то же напечатано в начале вывода: "проект создан ... gid").
**Повтор имени задачи внутри одного плана - ошибка**, скрипт откажется до первого сетевого вызова: какую из двух строк исполнять - неоднозначно. Имена в плане должны быть уникальны (сравниваются без хвостовых пробелов).
## Закрытие отчетного периода
Регулярная процедура у любого подрядчика, работающего по актам: собрать выполненные работы в секцию периода, показать часы, отдать на подпись, закрыть. Порядок - четыре команды, каждая со своим dry-run:
1. **`tasks`** - завести секцию периода и новые задачи в ней (секции создает только эта команда).
2. **`move`** - перенести в ту же секцию уже существующие задачи из рабочих секций. План: `{project, section, tasks: ["<имя или gid>", ...]}`. Задача уже в целевой секции - "без изменений", а не ошибка.
3. **`summary`** - сводка часов: построчно `часы | задача` и итог. **Делать до закрытия**, пока расхождение еще можно исправить: сумма уходит в документ, по которому выставляется счет.
4. **`complete`** - пакетно проставить "выполнено". План: `{project, section}` (вся секция) либо `{project, tasks: [...]}`; ровно одно из двух, иначе отказ. **Необратимо**: снятие галки скрипт не делает.
**Форма `{project, section}` - только предпросмотр, `--send` с ней отклоняется.** Она набор не фиксирует: между показом и записью в секцию могли добавить задачу, и она закрылась бы тоже - на общей доске это чужая задача, закрытая без спроса. Инвариант "dry-run показывает ровно то, что сделает send" тут невыполним в принципе, поэтому вместо предупреждения стоит запрет: dry-run печатает готовый план по gid, им и закрывают.
```
закрыть этот набор - планом по gid (форма по секции пишет только через него):
{"project": "12345", "tasks": ["1001", "1002"]}
```
Секция, в которой все уже закрыто, - штатный no-op, а не отказ.
**Закрытие глобально для задачи, а не для доски.** Флаг "выполнено" принадлежит самой задаче: multi-homed задача, видимая в нескольких проектах, закроется во всех. Предпросмотр этого не показывает - это свойство Asana, а не скрипта.
**Подзадачи в перечень не попадают.** `move`, `complete` и `summary` работают со списком задач проекта или секции, а подзадача в нем не значится. Закрывать и считать их надо отдельно.
**Часы берутся из имени задачи регуляркой.** Дефолт:
```
(?<![\w.,\-\u2010-\u2015\u2212])(\d+(?:[.,]\d+)?)\s*(?:ч|h)\b
```
Первое число перед "ч"/"h", у которого слева нет буквы, цифры, точки, запятой или тире любого начертания. Ловит `(dwl 20ч)` и `(dwl 23,5ч)`; не ловит "за 3 часа", "2 hotfix", `(20-25ч)`, `1,5,5ч`, `1e3ч`. Своя конвенция - флаг `--hours-re` с группой захвата (без группы или с битой регуляркой - отказ). Формат имен задач - **проектная договоренность, в каноне ее нет**: держи ее в правиле своего проекта.
**Нераспознанные часы печатаются строкой с `?` и в сумму не входят**, а в конце идет явное предупреждение. Молчаливый пропуск занизил бы счет неотличимо от честной суммы (`rules/silent-failure.md`).
Дефолт намеренно **отказывается** от неоднозначного, а не угадывает: `(20-25ч)`, `1,5,5ч`, `1e3ч` - это `?`, а не 25, 5,5 и 3. Раньше такие строки молча уходили в сумму счета. Абсурдно большие числа тоже отбрасываются: строка из четырехсот цифр давала бесконечность и отравляла итог целиком.
**Сумма считается в десятичной арифметике** (`Decimal`), а не в двоичной: `0,005` трижды в float дают `0,01` вместо `0,015`, и расхождение с ручной проверкой возникало бы на ровном месте.
**Своя регулярка - твоя ответственность.** Проверяется только то, что она компилируется и имеет группу захвата; смысл и катастрофический бэктрекинг (`((a+)+$)` на длинном имени вешает процесс) на совести автора. Если сомневаешься - оставь дефолт и переименуй задачи.
## Связи "откуда выросла эта работа"
У отчетной задачи за выполненные работы блокировок нет по определению - она никого не ждет, - но показать, из чего работа выросла, надо. Для этого у задачи в плане `tasks` есть поле `related`:
```json
{"name": "Отчет: доработка бота (dwl 20ч)", "related": ["Правки формы заявки", "1216875635976802"]}
```
Скрипт пишет в описание задачи секцию `СВЯЗАННЫЕ ЗАДАЧИ - откуда выросла эта работа:` с упоминаниями. Что важно:
- **блок в описании один на все скрипты**, и каждый перезаписывает только свои секции: секция блокировок от `asana-blockers` переживает запись, и наоборот. Конвенция и ее устройство - в скилле `asana-blockers`, раздел "Блок один на все скрипты";
- **каркас берется из `scripts/asana-blockers.py`** боковым импортом - оба скрипта должны лежать в `scripts/` проекта. Нет соседа - команда скажет об этом и назовет, что взять из канона;
- **идемпотентно**: повторный прогон с тем же `related` ничего не пишет;
- **ссылаться можно на имя или gid**, в том числе на задачу, создаваемую этим же прогоном (блок пишется после создания всех задач). Задачи плана участвуют в резолве наравне с доской, поэтому имя из плана, совпавшее с gid чужой задачи, - отказ, а не молчаливый выбор одной из трактовок;
- **остаточный риск, который не закрывается**: описание задачи читается дважды - на проверке до записей и на самом слиянии. Если между этими моментами кто-то заменит описание блоком старого формата, прогон остановится уже после создания задач. Блок при этом не пострадает, но доска останется наполовину обработанной; лечится прогоном `asana-blockers` и повтором;
- **ключа `related` нет - задача не трогается вовсе**; `"related": []` наоборот означает "связей больше нет" и снимает нашу секцию. Разделение намеренное: без него последнюю связь нельзя было бы убрать иначе как руками;
- **блок первой редакции `asana-blockers`** (термин плюс голая ссылка) команда сливать не умеет и **отказывается** работать с такой задачей - перезапись стерла бы записанные в нем блокировки. Лечится прогоном `asana-blockers`: он перепишет блок в новый формат;
- **ссылка на задачу с неуникальным именем - отказ**: связь ушла бы на произвольную из одноименных. Указывай gid.
## Идемпотентность и повторные прогоны
- **Ключ - имя задачи.** Повторный прогон `tasks` с тем же планом не плодит дубли; изменение имени в плане означает новую задачу. Если одноименные задачи открыта и закрыта, сопоставляется открытая. Секции тоже переиспользуются по имени.
- **Матчинг не различает, кто задачу создал.** Одноименная задача, заведенная человеком руками, считается плановой: ей подтянется срок (а если она multi-homed - срок изменится во всех проектах, где она видна). В dry-run это видно как неожиданный `ПРОПУСК`/`СРОК` у задачи, которую ты считал новой - разберись до `--send`, обычно достаточно переименовать задачу в плане.
- **`move` и `complete` идемпотентны** по той же модели: перенос в ту же секцию и закрытие закрытого - это "без изменений", а не ошибка.
- **Повтор после обрыва безопасен**: сделанное станет "без изменений". Скрипт при обрыве печатает в stderr, сколько задач **подтвержденно** обработано - исход последнего запроса неизвестен всегда, ответ мог потеряться уже после применения.
- **Числовая ссылка, совпавшая с именем другой задачи, - отказ.** Строка из цифр читается как gid, но задача может быть и НАЗВАНА числом ("2026"). Совпали обе трактовки - скрипт не выбирает, а называет обе.
- **Одноименные секции - отказ** во всех командах, включая `tasks`. Asana их разрешает; молчаливый выбор одной из двух отправлял бы запись в непредсказуемую.
- **Две или более открытых одноименных задачи - отказ до первой записи** в `tasks`, `move`, `complete` по списку и `related`. Область сопоставления - весь проект, не отдельная секция. Отказ с кодом 1 перечисляет имя, gid, секцию и срок каждого открытого кандидата; dry-run и `--send` показывают один текст. Указанный явно gid работает и для закрытой задачи.
- **Часы округляются при разборе**, а не при печати: поэтому сумма показанных строк всегда равна показанному итогу. Шаг - две цифры после запятой.
- **Неоднозначное имя задачи - отказ до записи.** Выбор наугад тут означает изменение не той задачи на общей доске. Ошибки собираются по всем ссылкам плана сразу, а не по одной за прогон.
- **`move` не создает секцию**: промах в имени вероятнее намерения завести секцию именно этой командой (`"Отчет"` против `"Отчёт"` - разные строки). Отказ печатает список существующих секций; заводит их `tasks`.
- **`create` НЕ идемпотентен**: повторный прогон создаст второй проект с тем же именем. Развернул доску - дальше только `tasks` с gid проекта.
- **Срок синхронизируется.** Если `due_on` существующей задачи отличается от плана - подтягивается (в dry-run видно `было -> стало`). Практический смысл: план дат живет в JSON под рукой, пересмотр сроков после этапа - правка файла и один прогон, а не ручной обход доски. Убрать дату из плана - НЕ стирает ее на доске.
## Авторизация
Порядок выбора PAT - как в `asana-comments`, раздел "Авторизация".
Как получить PAT по шагам и три способа словить 401 (OAuth-токен MCP-сервера, client_id, client_secret - все не подходят) - в скилле `asana-comments`, раздел "Авторизация".
## Связанные файлы
- `scripts/asana-project.py` в корне проекта - эталонный скрипт (workspaces / create / tasks, dry-run по умолчанию).
- `scripts/asana-comments.py` - чтение ленты комментариев (read-only сосед по токену).
- `scripts/asana-blockers.py` - связи задач в описании; отсюда берется каркас технического блока для `related` (нужен в `scripts/` проекта, если пользуешься этим полем).
- `~/.config/asana/auth.json` - PAT, вне репозитория, права 600.