almdev Технический блог

Анатомия рабочей постановки: пять блоков
Контракт задачи на одну страницу вместо одной строки в чате.

Задача «добавь подписку на задачи» превратилась в подсистему на 640 строк. Разбираю контракт из пяти блоков — цель, в объёме, вне объёма, ограничения, готово когда — и показываю, почему «вне объёма» экономит больше остальных четырёх вместе взятых.

Подкатегория: Промпты

Чтение
6 мин
Технологии / версии
Symfony · Промпты · Coding agent · Воркфлоу
Серия
Цикл «Промпты» · часть 1 из 6
5 мая 2026 · 6 мин · 2 просмотра · AI
Контракт задачи из пяти блоков рядом с распечаткой большого диффа
Промпты 05/2026

Формулировка «добавь подписку на задачи» стоила мне вечера ревью. Ответ пришёл рабочий, тесты зелёные, стиль проекта выдержан. Дифф — 640 строк, из которых примерно двести я не заказывал и не хотел.

Это не история про плохую модель. Это история про то, что задача без явно заданных границ разрастается сама.

Что выросло из одной строки

Проект — Symfony 8, модульный монолит, модули Core, Tasks, Docs. Мне нужно было, чтобы пользователь мог подписаться на задачу и получать письмо, когда её статус меняется. В голове это выглядело как таблица связей, подписчик в обработчике события и одно письмо.

Получил я:

migrations/Version20260421084512.php      таблица task_subscription
src/Tasks/Entity/TaskSubscription.php
src/Tasks/Repository/TaskSubscriptionRepository.php
src/Tasks/Service/SubscriptionManager.php
src/Tasks/Service/DigestBuilder.php            дайджест, которого я не просил
src/Tasks/Service/NotificationPreference.php   настройки частоты, тоже нет
src/Core/Entity/UserNotificationSettings.php   влез в чужой модуль
src/Tasks/MessageHandler/TaskChangedHandler.php
src/Tasks/Controller/SubscriptionController.php
templates/tasks/subscription/settings.html.twig
+ 6 файлов тестов

Три механизма сверх задачи: дайджест раз в день, настройки частоты уведомлений на пользователя и таблица в Core под общие настройки нотификаций. Каждый сам по себе разумный. Вместе они меняют модель данных проекта решением, которое я не принимал.

Отдельно раздражает вот что: отклонить это дороже, чем принять. Код написан прилично, тесты на него есть, откат тянет за собой миграцию. Проще было махнуть рукой. Так в проектах и заводятся подсистемы, происхождение которых через год никто не помнит.

Пять блоков

После третьего такого случая я перестал формулировать задачи в одну строку и завёл шаблон. Пять блоков, по абзацу на каждый, всё вместе помещается на экран.

Цель. Одно предложение о том, что изменится для пользователя. Не «реализовать подписки», а «пользователь нажимает кнопку на задаче и получает письмо, когда меняется статус». Цель формулируется наблюдаемым результатом, потому что по ней потом проверяется готовность.

В объёме. Перечисление файлов, слоёв или сущностей, которых работа касается. Не описание архитектуры — просто список зон.

Вне объёма. Перечисление того, чего в этой задаче быть не должно, даже если оно напрашивается.

Ограничения. Конвенции проекта, применимые к этой задаче: где лежат миграции, через что идут переводы, какой слой не трогаем.

Готово когда. Условие завершения, проверяемое командой или действием, а не суждением.

Пять блоков пишутся минут за пять. Ревью диффа на 640 строк занимает час.

Почему «вне объёма» важнее остальных

Из пяти блоков четыре формулируют работу. Пятый её ограничивает, и именно он экономит больше всего.

Причина в том, как агент достраивает задачу. Он видит подписку и знает по тысячам похожих кодовых баз, что рядом с подпиской обычно живут настройки, дайджест и предпочтения по каналам. Достройка выглядит как забота: пользователь же захочет отключить письма. Отсутствие запрета читается как разрешение, а иногда и как намёк.

Мой список «вне объёма» для той задачи выглядел бы так:

ВНЕ ОБЪЁМА
- Настройки частоты и каналов уведомлений. Пишем письмо сразу, одно на событие.
- Дайджест и любая агрегация.
- Изменения в модуле Core. Таблица и сущность живут в Tasks.
- Отписка по ссылке из письма. Отдельная задача.
- Уведомления по любым событиям, кроме смены статуса.

Пять строк. На втором заходе с ними дифф стал 180 строк.

Формулировать «вне объёма» проще, чем кажется. Спросите себя, что бы вы сами дописали, если бы задача была вашей и времени было много. Вот это и запрещайте.

Критерий готовности, который проверяется

Самый бесполезный критерий — «работает корректно». Второй по бесполезности — «покрыто тестами».

Готовность должна проверяться командой или последовательностью действий, у которой есть однозначный исход:

ГОТОВО КОГДА
- make check зелёный.
- Новый тест TaskSubscriptionTest: подписался, статус сменился,
  в транспорте одно письмо; отписался — писем нет.
- На странице задачи есть кнопка, её состояние переживает перезагрузку.
- Дифф не трогает src/Core.

Последний пункт — тоже критерий готовности, хотя выглядит как ограничение. Такие вещи полезно дублировать: то, что записано в «вне объёма», в конце проверяется командой git diff --stat.

Разница между «покрыто тестами» и описанием конкретного теста — в том, кто выбирает сценарий. В первом случае выбирает исполнитель, и выбирает он тот сценарий, который проще написать зелёным.

Ограничения — это список конвенций, а не пожелания

Блок ограничений сползает в вежливость чаще прочих. «Следуй стилю проекта», «пиши качественный код», «учитывай производительность» — всё это не ограничения, потому что не отсекают ни одного варианта.

Работает перечисление:

ОГРАНИЧЕНИЯ
- Миграции — только классы Doctrine в migrations/, никакого сырого SQL.
- Переводы во всех трёх локалях: en, ru, de. Иначе make check красный.
- Сервис возвращает строку ошибки, не бросает исключение (контракт проекта).
- Контроллер тонкий: логика в сервисе.
- Сущности Tasks не ссылаются на сущности Docs напрямую.

Половина этих строк живёт в AGENTS.md, и в контракт задачи их можно не переписывать. Я всё равно дублирую те две-три, которые относятся к задаче вплотную. Файл контекста читается один раз в начале сессии, а на пятидесятом инструменте подряд из него остаётся не всё.

Контракт целиком

Вот как выглядела та же задача на втором заходе.

ЦЕЛЬ
Пользователь подписывается на задачу кнопкой на странице задачи и получает
письмо при смене её статуса. Отписка — той же кнопкой.

В ОБЪЁМЕ
- Модуль Tasks: сущность подписки, репозиторий, сервис, обработчик события
  смены статуса, контроллер на две ручки, кнопка в шаблоне задачи.
- Одна миграция Doctrine.
- Один функциональный тест.

ВНЕ ОБЪЁМА
- Настройки частоты и каналов уведомлений.
- Дайджест и любая агрегация.
- Изменения в модуле Core.
- Отписка по ссылке из письма.
- Уведомления по событиям, кроме смены статуса.

ОГРАНИЧЕНИЯ
- Миграции — классы Doctrine в migrations/.
- Переводы en/ru/de.
- Сервис возвращает строку ошибки, не бросает исключение.
- Письмо отправляется через шину сообщений, не синхронно в обработчике.

ГОТОВО КОГДА
- make check зелёный.
- Тест: подписка → смена статуса → одно письмо в транспорте; отписка → ноль.
- Кнопка на странице задачи, состояние переживает перезагрузку.
- git diff --stat не содержит src/Core.

Результат: 180 строк, 7 файлов, одна миграция. Ревью занял двадцать минут, из которых десять я потратил на спор с самим собой, правильно ли класть отправку письма в обработчик шины.

Разница между заходами — не в качестве кода. Код в обоих случаях приличный. Разница в том, что второй дифф я целиком понимаю и могу защитить на ревью через полгода.

Где контракт вреден

Есть класс задач, где пять блоков только мешают. Это задачи, в которых я не знаю правильного ответа.

«Разберись, почему страница списка задач тормозит на организации с сорока тысячами записей» — здесь «в объёме» неизвестен. Причина может оказаться в запросе, в шаблоне, в сериализации, в индексе, в жадной загрузке связей. Если я напишу «в объёме: репозиторий задач», я срежу поиск до своей гипотезы. А гипотеза у меня как раз слабая, иначе я бы уже починил.

Для таких задач я оставляю два блока из пяти: цель и ограничения. Цель — «найти причину и показать замеры». Ограничение — «ничего не менять, только диагностика». Всё остальное — простор, ради которого я и зову исполнителя.

Признак, по которому различаю: если я могу сформулировать «вне объёма» — контракт нужен. Если не могу, потому что сам не понимаю границ, — контракт преждевременен, сначала разведка.

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

Что осталось

Шаблон живёт у меня в заметках и переносится в задачу копированием. Пробовал сделать из него скилл, чтобы срабатывал автоматически, — не прижилось: контракт пишу я, а не агент, и автоматизировать тут нечего.

Чего не хватает — привычки писать «вне объёма» до того, как обжёгся. Первые три пункта в этом блоке у меня всегда появляются задним числом, после того как в диффе обнаружился очередной механизм с настройками, которого никто не просил.

Серия

Цикл «Промпты»

#symfony #prompting #coding-agent #workflow #php