Мобильное приложение, команда из шести человек, один part‑time тех‑лид. Агенты пишут код давно — вопрос был в том, как заставить их писать его как мы.

С чего всё началось

Я тех‑лид мобильного приложения в спортивной нише: профили игроков, поиск партнёров, бронирование, платежи, чаты, лиги и турниры. React Native на Expo, TypeScript, Redux Toolkit, отдельный бэкенд на Node.js. Команда: я на клиенте, два бэкендера, два QA, дизайнер. Я работаю над проектом part‑time.

AI‑инструменты у нас появились сразу: Cursor в IDE, Claude в чатах у аналитиков. И первое время всё выглядело хорошо — агент пишет React Native, код компилируется, задача закрыта. Проблемы начинались через неделю, на ревью.

Агент не знал, что у нас цвета берутся только из useTheme(), а не из hex в стилях. Не знал, что один файл — один компонент, а компонент — это папка с index.ts. Не знал, что картинки мы рендерим через собственный AppImage, а не голый Image. Не знал, что в репозитории нет папок android/ и ios/, потому что мы на managed Expo, и любая правка манифеста делается через app.config.js и плагины. Каждый раз он писал «типовой React Native» — грамотный, но чужой.

Мы, конечно, писали всё это в промпт. Проблема в том, что промпт — это просьба. Агент читает его в начале сессии, а через двадцать инструментов и три файла контекст уплывает, и он снова лезет в android/ или тянет hex прямо в StyleSheet. Кто‑то из команды забывал вставить контекст вообще. Аналитик, ставящий задачу через Claude, не знал, какой контекст вставлять.

Сформулировались две боли:

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

  2. Следование правилам. Правило, которое агент может «забыть», — не правило. Нужно, чтобы нарушения блокировались, а не оставались на совесть.

Дальше — что мы построили, по слоям.


Слой 1. Документация с разными адресатами

Первая ошибка, которую мы сделали и быстро откатили, — один большой README «для всех». Люди не читали его целиком, агентам он засорял контекст, аналитикам там было 80% лишнего.

Разделили по аудиториям:

Файл

Кто читает

Что внутри

README.md

люди

онбординг, env, сборки, ограничения Expo Go vs build

CLAUDE.md

разработчики и аналитики в ИИ‑чатах

стек, сущности, как ставить задачи, что ломает PR, команды проверки

AGENTS.md

автономные агенты (Cloud, SDK, бот)

операционка: запреты, ветка bot/pr-*, сценарий «Создай PR»

.cursor/rules/*.mdc

Cursor Agent

жёсткие конвенции кода, всегда или по glob

docs/*.md

все

доменные документы хрупких зон

Главный принцип — не дублировать. Если конвенция про цвета описана в theme-colors.mdc, её нет в README. Если процесс PR описан в AGENTS.mdCLAUDE.md просто ссылается на него.

Отдельно про доменные документы. В приложении есть зоны, которые ломаются от любого неосторожного движения: лиги и турниры, очередь промптов на главной, создание матча с предзаполнением, гостевой режим, оффлайн с кэшем и сокетами. Для каждой — файл вида «карта кода + что не ломать + текущий факт + бэклог». Без changelog: агенту не нужна история, ему нужно текущее состояние.

Правило процесса простое: задача в зоне → сначала прочитать доменный документ, потом код. Изменил конвенцию — обнови документ в том же PR.

Правила Cursor (.cursor/rules/) — это уже конкретика: алиасы импортов, структура компонента, AppImage/AppAvatarвместо нативных, тема только через хук, пин версии Reanimated. Плюс agent-workflow.mdc: саморевью диффа перед сдачей, финальный ответ всегда с блоком «Как тестировать», соседний бэкенд только читать.

Это решило первую боль наполовину: контекст стал структурированным. Но «прочитал» всё ещё не значило «соблюдает».


Слой 2. Hooks вместо просьб

Cursor умеет запускать пользовательские скрипты на событиях жизненного цикла агента. Это оказалось самой сильной частью всей настройки, потому что hook — это не текст в контексте, это рантайм, который агент не может проигнорировать.

.cursor/hooks.json у нас выглядит примерно так (упрощённо):

{
  "hooks": {
    "sessionStart": [{ "command": "node .cursor/hooks/session-start.js" }],
    "preToolUse": [{
      "matcher": "Write|StrReplace|Delete",
      "command": "node .cursor/hooks/guard-write.js"
    }],
    "beforeShellExecution": [{ "command": "node .cursor/hooks/guard-shell.js" }],
    "afterFileEdit": [{ "command": "node .cursor/hooks/track-edit.js" }],
    "afterShellExecution": [{ "command": "node .cursor/hooks/track-checks.js" }],
    "stop": [{ "command": "node .cursor/hooks/ensure-checks.js", "loop_limit": 4 }]
  }
}

Что делает каждый:

sessionStart — мягкое напоминание в контекст: прогоняй lint и type‑check, не трогай android/ios/.env и соседний бэкенд, при крупной интеграции обнови доки. Это закрывает боль «забыл вставить контекст»: теперь его вставляет рантайм, а не человек.

preToolUse → guard-write.js — жёсткий запрет. Любая попытка записи в android/ios/.env или по пути ../backendвозвращает deny. Агент получает отказ с объяснением и идёт другим путём — через app.config.js, как и положено.

// guard-write.js, суть
const DENY = [/^android\//, /^ios\//, /^\.env/, /\.\.\/backend\//];
const target = normalize(input.tool_input.path);
if (DENY.some(re => re.test(target))) {
  output({ permission: "deny",
           reason: "Native tree, env и backend правятся только человеком. Для нативки — app.config.js / plugins." });
}

beforeShellExecution → guard-shell.js — то же для шелла: force‑push в main/master, git reset --hard, опасный rm -r, правки нативного дерева через sed, коммит секретов, запись в бэкенд. Список короткий и понятный: это ровно те команды, которые нельзя откатить.

afterFileEdit / afterShellExecution — трекинг. Запоминаем, какие файлы менялись и гонялись ли уже npm run lint и npm run type-check. Это состояние нужно последнему hook'у.

stop → ensure-checks.js — ключевой. Срабатывает, когда агент считает, что закончил. Логика:

  1. Если менялся код, а lint и type‑check не прогонялись или были красными — не отпускаем, возвращаем follow‑up «прогони проверки».

  2. Если проверки зелёные — follow‑up на ревью изменённых файлов по чеклисту правил: один компонент на файл, алиасы, тема, AppImage.

  3. Если сработал триггер «крупная интеграция» (новый native SDK, новая EXPO_PUBLIC_* переменная, правка eas.json) — follow‑up на синхронизацию README / CLAUDE / rules / env.

loop_limit: 4 — чтобы агент не крутился бесконечно, если что‑то системно не чинится.

Это закрыло вторую боль. Правило «прогони lint перед сдачей» перестало быть правилом и стало свойством среды. Агент не может сдать красный код, потому что рантайм не даст ему остановиться.

Важный побочный эффект: hooks сделали возможной автономию. Пока качество держалось на «агент прочитал и вроде соблюдает», доверить ему PR без человека было нельзя. Когда границы стали жёсткими, стало можно.


Слой 3. Safe‑list: что агенту можно одному

Автономный агент опасен не тем, что пишет плохой код, а тем, что не понимает, где заканчивается его компетенция. Поэтому появился docs/agent-safe-tasks.md — явная граница.

Можно автономно, с авто‑PR:

  • копирайт и тексты;

  • мелкий UI‑баг в 1–3 файлах;

  • узкая правка оффлайн‑UI с указанным путём и критериями приёмки;

  • утилита плюс тест к ней;

  • мелкий слайс RTK или тип по ТЗ;

  • документация;

  • точечный lint‑fix.

Только человек:

  • новый native SDK или плагин;

  • push, OAuth, платежи, deep linking;

  • новая архитектура оффлайна, сокетов, AuthGuard;

  • лиги и турниры end‑to‑end;

  • app.config.jsplugins/eas.json;

  • любой рефакторинг «по дороге»;

  • секреты.

И правило на границе: сомневаешься — не открывай PR, напиши короткий отказ в 2–3 предложения. Отказ агента — это нормальный, ожидаемый результат, а не сбой.

Список выглядит консервативным, и это намеренно. Он ограничивает не способности агента, а ущерб от его ошибки. Мелкий UI‑баг в трёх файлах ревьюится за минуту; сломанный OAuth‑флоу ищется день.


Слой 4. Telegram‑бот как единый вход команды

К этому моменту у нас была IDE с правилами и hooks. Но команда живёт не в IDE. Аналитики ставят задачи в Notion, дизайнер — в Figma, метрики — в AppMetrica, релизы — в GitHub Actions, обсуждения — в Telegram. Пять инструментов, между которыми переключается один тех‑лид на part‑time.

Бот появился как оркестратор. Стек: TypeScript, grammy, Cursor Agent SDK, Notion API, Figma REST, AppMetrica Reporting API, gh CLI. Доступ — по whitelist Telegram‑ID.

Ключевая архитектурная идея: бот не имеет собственных правил качества кода. Он вызывает тот же класс агента через SDK с settingSources: ["project"] и рабочей директорией в checkout мобильного репозитория. Агент подхватывает AGENTS.md.cursor/rules и hooks проекта. Мозг конвенций живёт в одном месте — в репозитории продукта; бот только маршрутизирует.

Telegram (whitelist)
  → /ask, /analyze  → Cursor Agent, read-only (mobile ± backend)
  → /pr, /pr_qa     → Cursor Agent, write в mobile + project settings
  → /task_*         → Notion
  → /figma_*        → Figma REST → PNG в чат
  → /metrics_*      → AppMetrica (+ агент для вопросов на естественном языке)
  → /release        → gh workflow run → EAS

Второе принципиальное решение — жёсткое разделение read и write/ask и /analyze никогда не пишут. Только явные /pr* меняют код, и только в мобильном репозитории — в бэкенд бот не пишет по определению, потому что hooks мобильного проекта это запрещают.

Как работает /pr

  1. На вход — текст задачи или ссылка на Notion‑карточку (тогда бот сам вытянет описание).

  2. Проверка gh auth. Это добавили после нескольких случаев «код написан, ветка есть, PR нет» — из‑за протухшего токена.

  3. Агент запускается в plan mode: читает задачу, доменный документ зоны, safe‑list, и пишет план. Если задача вне safe‑list — здесь и останавливается с отказом.

  4. Тот же агент переходит в agent mode и реализует план. Hooks проекта работают как в IDE: запись в запрещённые зоны блокируется, stop‑hook требует зелёные lint и type‑check.

  5. Ветка bot/pr-<slug> от master, push, gh pr create с описанием и блоком «Как тестировать».

  6. Checkout возвращается на базовую ветку. Это тоже пришло из практики: локальная IDE залипала на bot/pr-*, и следующая сессия начиналась не там.

Каждый /pr — свежая сессия без наследования контекста. Для /ask наоборот: сессия хранится по chatId, и уточняющие вопросы продолжают диалог.

/pr_qa: очередь багов

Здесь всё сошлось в рабочий цикл, и это то, ради чего всё делалось.

У нас есть агент‑аналитик на Claude, который раз в день прогоняет приложение на эмуляторах: смотрит, какие задачи были залиты с прошлого раза, проверяет их и делает регрессионные прогоны. Найденные проблемы уходят в Notion в доску QA с меткой Frontend.

Дальше я вызываю /pr_qa. Бот:

  • берёт задачи из очереди, фильтрует по Frontend и эвристикам safe‑list;

  • группирует до трёх задач в один PR, если они про один файл, один продуктовый флоу или один коммит;

  • для каждой группы — цикл plan → agent → lint → PR;

  • статусы в Notion не трогает — только код и PR. Статусы меняет человек после ревью.

/cancel рвёт текущий прогон и очередь.

В результате бóльшая часть мелких и средних задач — фиксы, баги, копирайт, точечные правки UI — закрывается этим циклом. Я ревьюю PR, а не пишу их.

Честная оговорка: большие фичи так не делаются. Турниры, например, мы с бэкендером сделали руками — два разработчика, доменный документ, ADR на бэкенде, много обсуждений. Агент участвовал как инструмент в IDE, но не как автономный исполнитель. Safe‑list здесь работает как задумано: он просто не пропускает такие задачи в автоматику.

Остальное

/release — только workflow_dispatch уже настроенного release.yml в мобильном репозитории (development / staging / production через EAS). Бот не знает секретов и не собирает ничего сам.

/figma_show индексирует файл иерархически и присылает PNG нужного фрейма прямо в чат; тот же slim‑индекс подмешивается в /ask про UI.

/metrics_ask — вопросы к AppMetrica на естественном языке через агента поверх Reporting API.

Notion — общий OAuth на весь whitelist, несколько досок, роли исполнителей в конфиге. Аналитик создаёт задачу из чата, не открывая Notion.


Бэкенд: другой подход

На бэкенде (Node.js, Fastify, PostgreSQL, модульный монолит) мы пошли иначе, и это важно проговорить: не всё нужно закрывать hooks.

Там нет .cursor/hooks и AGENTS.md. Вместо этого — domain‑first:

  • CONTEXT-MAP.md в корне: какие модули уже «обложены» документацией;

  • CONTEXT.md в каждом зрелом модуле — глоссарий: канонические термины, что с чем не путать (League ≠ Tournament ≠ Championship), продуктовые правила;

  • ADR рядом с модулем, в src/modules/<mod>/docs/adr/, а не в общей wiki;

  • локальный markdown‑трекер .scratch/<feature>/spec.md + issues/*.md со статусами и гейтом ready-for-agent — агент берёт только полностью специфицированные тикеты.

Почему так: на бэкенде ущерб от «неправильного слова» выше, чем от «неправильного файла». Если агент называет одно и то же «pair», “team” и «partner» в трёх местах, через месяц никто не разберётся в домене. Глоссарий как закон языка оказался там важнее, чем блокировка rm -rf.

Модули лиги — эталон: семь ADR, полный глоссарий. Остальные документируются лениво, по мере того как термины реально резолвятся. Заводить пустые CONTEXT.md “на всякий случай” — плохая идея, они только шумят.

Мобильный клиент читает CONTEXT и ADR бэкенда, но не пишет туда. Если ТЗ или Figma расходятся с фактическим API, агент клиента формирует файл handoff'а по шаблону — и это единственный канал «фронт → бэк». Никаких молчаливых правок контракта с чужой стороны.


Что не сработало и где границы

Без этого раздела статья была бы рекламой, поэтому честно.

Один README для всех. Первая версия. Не работала ни для кого. Разделение по аудиториям — первое, что мы сделали правильно, и только со второй попытки.

Правила без enforce. Промежуточный этап между «промпт» и «hooks». .cursor/rules есть, агент их читает — и всё равно через двадцать шагов делает по‑своему. Правила нужны, но как справочник, а не как гарантия. Гарантия — только hooks.

«Код есть, PR нет». Несколько раз. Причина — gh auth. Решение тривиальное (проверка перед стартом), но до него агент отчитывался об успехе, а результата не было.

Залипание IDE на ветке бота. Локальный рантайм бота работает в том же checkout, что и IDE. После /pr рабочая копия оставалась на bot/pr-*. Добавили restore на базовую ветку до и после.

Stop‑hook без лимита. В первой версии агент мог бесконечно крутиться на «прогони lint» → “не проходит” → «прогони lint». Отсюда loop_limit: 4.

Большие фичи. Уже сказал: турниры, лиги end‑to‑end, платёжные сценарии, новые native SDK — руками. Не потому, что агент не может написать код, а потому что цена ошибки и объём ревью делают автономию невыгодной. Safe‑list — это не временное ограничение «пока агенты не поумнеют», это осознанная граница ответственности.

Hooks не доехали до бэкенда. Там пока держимся на глоссариях и трекере. Это работает для нашего размера команды, но я не уверен, что масштабируется.


Итог

Что получилось в сухом остатке:

  • Контекст вставляет рантайм, а не человек. sessionStart + слоистые доки. Никто не забывает и не вставляет лишнего.

  • Правила блокируют, а не просят. guard-writeguard-shellstop с обязательным lint/type‑check. Агент физически не может сдать красный код или тронуть нативное дерево.

  • Автономия там, где она дешёвая. Safe‑list отделяет «мелкий UI‑баг» от «платёжный флоу», и агент сам отказывается от второго.

  • Один вход вместо пяти инструментов. Telegram‑бот на Agent SDK, который наследует правила продукта, а не дублирует их.

  • Замкнутый цикл. Агент‑QA тестирует → очередь в Notion → /pr_qa → PR → человек ревьюит. Мелкие и средние задачи закрываются без моего участия в написании кода.

Всё это — файлы в репозитории: несколько markdown‑документов, hooks.json, пять коротких скриптов, safe‑list на страницу. Ни один из них не требует ничего, кроме Cursor и Node.js. Бот — отдельный небольшой сервис, но его ценность на 90% в том, что он ничего не знает о конвенциях: он просто запускает агента в правильной директории с правильными настройками.

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

Буду рад вопросам в комментариях — особенно от тех, кто делал похожее на бэкенде с hooks, у нас там пока пробел.