Днём я занимаюсь корпоративными системами, а по вечерам делаю свои проекты с помощью Claude Code. В какой-то момент я заметил, что пишу код всё меньше, а работаю диспетчером всё больше: напоминаю агенту продолжить, перепроверяю его «готово», а утром обнаруживаю, что в час ночи закончился лимит подписки и всё стояло. Я написал для этого локальный надсмотрщик, AIWorks, а потом отдал ему на сутки его собственный репозиторий. Ниже — как он устроен, что получилось и, главное, что сломалось по дороге.

Где именно болит

Агенты пишут хороший код. Проблемы начинаются вокруг кода.

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

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

Четыре правила, с которых я начал

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

  1. План утверждается один раз. Дальше надсмотрщик работает только в его рамках, а расширить рамки можно только новой, более высокой версией разрешения.

  2. Агент не проверяет сам себя. Результат каждого шага проверяет отдельная свежая сессия, которая этот код не писала.

  3. Никаких слияний и деплоя. Надсмотрщик создаёт ветки, пул-реквесты и комментарии — и всё. Сливаю я сам или поручаю это ассистенту, которому доверяю.

  4. Человека спрашивают только о том, что может решить только он. Ожидание лимита, перезапуск после сбоя, повторная проверка — не повод его беспокоить.

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

Как это устроено

Надсмотрщик — постоянно работающий процесс на Node.js у меня на домашнем компьютере с Windows. Состояние хранится в локальной SQLite, результаты публикуются в GitHub. Пока работы нет, он не обращается к модели вообще, так что простой ничего не стоит.

Жизнь одного шага выглядит так:

план (JSON) ──► надсмотрщик ──► сборщик: Claude Code в отдельном git worktree
                                        │
                                        ▼
                            проверяющий: новая сессия, только чтение,
                            тот же коммит, повторный прогон тестов
                              │                          │
                       есть замечания               всё в порядке
                              │                          │
                 новый раунд с находками      push ровно этого коммита,
                                               пул-реквест с двумя отчётами

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

Сборщик — это запуск Claude Code без интерфейса, без моих личных настроек, хуков, MCP-серверов и памяти. Он работает внутри Windows Job Object с таймаутом и сердцебиением. Если шаг должен изменить код, результат засчитывается только в виде валидного JSON с хэшем нового чистого коммита. Рассказ «я всё сделал» без коммита — не результат.

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

После приёмки надсмотрщик пушит ровно проверенный коммит и открывает один пул-реквест. Перед этим он ищет уже существующий, поэтому повтор после сбоя не плодит дубли.

План — обычный JSON. Вот упрощённый пример из тестовых данных репозитория:

{
  "campaignId": "sample-chain",
  "authorization": { "version": 4, "grantedBy": "owner" },
  "target": { "repository": "example-org/sampleapp", "baseBranch": "main" },
  "executor": {
    "kind": "cli-chain",
    "providers": [
      { "kind": "claude-cli", "model": "opus" },
      { "kind": "codex-cli" },
      { "kind": "cursor-cli" }
    ],
    "implementerTimeoutMinutes": 60,
    "verifierTimeoutMinutes": 30
  },
  "tasks": [
    { "id": "T1", "deliverable": "commit", "goal": "Produce the first artifact.",
      "acceptance": ["artifact exists"], "dependsOn": [] },
    { "id": "T2", "deliverable": "commit", "goal": "Produce the second artifact.",
      "acceptance": ["artifact exists"], "dependsOn": ["T1"] }
  ]
}

Claude — основной исполнитель, Codex и Cursor — запасные. Переход к следующему происходит только из-за проблем самого провайдера: лимит, слетевший логин, недоступный инструмент. Отказ песочницы или проваленная проверка никогда не «обходятся» другим провайдером. Иначе независимая проверка превращается в перебор исполнителей до первого согласного.

Лимиты записываются на аккаунт вместе со временем сброса, а если оно неизвестно — на 30 минут. Если надсмотрщик упал, новый экземпляр по сердцебиению подхватывает уже работающего воркера, а не запускает второго. Две задачи планировщика Windows поднимают надсмотрщик и дашборд при входе в систему и каждые пять минут, если те остановились.

Смотрю я на всё это через локальный дашборд. Главный экран там называется Needs me («Нужен я»): в нём только то, что может сделать только человек, — решение, ревью, шаг, который остановился и сам повторяться не будет. Ожидание лимитов и паузы туда не попадают по определению.

Экран Needs me: решения и ревью, сгруппированные по проектам (проекты выдуманные)
Экран Needs me: решения и ревью, сгруппированные по проектам (проекты выдуманные)

Сутки на собственном репозитории

12–13 октября я отдал AIWorks его же репозиторий. Я утвердил общий план, а пошаговые планы и слияние проверенных пул-реквестов от моего имени вела отдельная сессия Claude. Сам я вмешивался несколько раз — например, чтобы обновить работающую копию надсмотрщика. Две ночные заметки о прогрессе в трекере сводились к «от вас ничего не нужно».

Цифры я считал по истории пул-реквестов и коммитов:

  • 63 завершённых изменения в виде пул-реквестов за 24,5 часа, каждое прошло независимую проверку;

  • 18 из них слиты между полуночью и семью утра;

  • на пике шли четыре независимые линии работы одновременно;

  • типичный шаг, сборка плюс проверка, занимал около 45 минут;

  • тестов стало 760 вместо 385;

  • около 53% кода AIWorks, примерно 25 тысяч строк, написано именно так.

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

Что сломалось по дороге

Это самая полезная часть, поэтому подробнее.

Закончилась квота GraphQL у GitHub

Публикация шла через gh pr create и похожие команды, которые ходят в GraphQL. При таком темпе квота аккаунта кончилась, и надсмотрщик начал каждые пять минут стучаться в закрытую дверь. Исправление — перевести все вызовы на REST через gh api, а при отказе по лимиту ждать ровно до времени сброса, которое сообщает GitHub, и публиковать один раз.

Заодно пришлось строго определить, что значит «слито». Теперь это совпадение URL, merged: true, head.sha, равный проверенному коммиту, правильная базовая ветка и полный 40-символьный merge_commit_sha. Всё, что слабее, — не доказательство.

На Windows прошло, на Linux упало

Хороший пример того, как зелёные тесты могут врать. Тест проверял, что предпроверка проекта отказывает, если в папке есть два файла, различающиеся только регистром, — Readme.md и README.md. На NTFS два таких имени существовать не могут, поэтому тест подсовывал фальшивый листинг каталога, но настоящий lstat оставлял как есть.

На Windows lstat("README.md") находил Readme.md, обе записи доходили до проверки коллизий, тест проходил. В CI на Linux файловая система чувствительна к регистру, lstat падал с ENOENT, сканер честно считал файл исчезнувшим во время сканирования и пропускал его. Коллизия не находилась, тест падал.

Код продукта был прав на обеих платформах: файл, который пропал во время сканирования, действительно надо пропускать. Неправ был тест — он описывал папку, которую можно подделать только на Windows. После нескольких таких случаев в промпт сборщика и проверяющего добавили правило: тест, который зависит от платформы, обязан это явно учитывать и при этом не ослаблять проверку на Windows. А «у меня на машине всё проходит» я теперь читаю как «проходит на одной платформе».

Агенты пересказывали числа по памяти

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

Теперь в промпте есть два правила, вот они как есть:

export const GATE_RULES = [
  // ...
  'Copy test summary lines verbatim from the command output (for node:test: # tests, # pass, # fail, # skipped); never retype or recall a tally.',
];

export const VERIFIER_TALLY_RULE = 'A test tally or other count in the report that does not match your own re-run of the same gate at the same head is a blocking finding: a wrong number is a false claim.';

Сборщик копирует итоговые строки дословно, а проверяющий сверяет их со своим прогоном. Несовпадение — блокирующая находка, даже если сам код хороший.

Проверяющий говорил «принято» и «провалено» одновременно

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

В первый раз провалилась информационная команда самого проверяющего: его запрос к GitHub упёрся в тот же лимит GraphQL. К изменению это отношения не имело. Во второй раз провал был настоящим. Новый экран дашборда не загружался в браузере, потому что сервер не отдавал новый файл. Проверяющий записал это как неблокирующее замечание (файл сервера был вне списка файлов шага) и всё равно принял. Пропусти надсмотрщик такой вердикт, я получил бы экран, который не открывается.

Правило, которое сейчас на ревью: провал — только то, что показывает дефект в изменении. Проверка, которая сработала как задумано (например, специально сломанный код действительно уронил тест), считается успехом. И «принято» никогда не может идти вместе с проваленной проверкой.

Один исчерпанный аккаунт — много одинаковых ошибок

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

Codex на Windows не может запустить тесты

Если первая проверка Claude срывалась, следующим проверяющим мог стать Codex. Внутри его песочницы на Windows, которая работает только на чтение, не ставятся зависимости и тесты не могут создать временные файлы. Codex делал аккуратный статический разбор и честно отвечал, что доказательств у него нет. Для него это правильный ответ, но раунд потрачен, а следующая попытка всё равно уходит к Claude.

Расширять песочницу нельзя: проверяющий, которому разрешено писать в рабочую копию, перестаёт быть независимым. Поэтому решение, которое сейчас в работе, проще: на Windows Codex не назначают проверять код.

Слепое ревью интерфейса

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

Он нашёл то, что я сам уже не замечал: в интерфейс протекали внутренние слова вроде campaign и executor, коды классов остановки и идентификаторы шагов, а отказ кнопки не объяснял, что делать дальше. Например, отказ возобновить работу теперь говорит, почему ничего не изменилось и что нажать после исправления, а технические подробности спрятаны под «Подробнее».

Сколько это стоит

Claude Code сам сообщает расход за запуск. Типичная сборка стоила около $5, проверка — $2–3. В более раннем замере выходило в среднем около $10 на один доставленный шаг с учётом повторов и проверок. На подписке это расход лимита, а не отдельный счёт. Дашборд показывает стоимость каждого проекта и шага и никогда не выдаёт неизвестную стоимость за ноль.

Сэкономленное внимание посчитать сложнее. Если держать в движении одно изменение вручную — подсказать, перепроверить, перезапустить после лимита, слить — занимает минут пятнадцать, то 63 изменения — это около 16 часов координации, которую никто не делал. Оценка грубая, но порядок, по-моему, честный.

Ограничения

Только Windows: воркеры запускаются внутри Windows Job Object, и на других системах надсмотрщик сознательно отказывается их стартовать. Он рассчитан на GitHub и Claude Code, а Codex и Cursor пока только частичные запасные варианты.

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

Что дальше

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

Попробовать

Код открыт под лицензией MIT: github.com/netbka/aiworks. Посмотреть дашборд на выдуманных данных можно на любой системе:

git clone https://github.com/netbka/aiworks
cd aiworks
npm install
node tests/dashboard/fixtures/review-store.mjs --port 4319

Мне интересно, что заставило бы вас доверить такой системе работу на ночь, и какие решения вы бы ей не отдали никогда.