Главный экран приложения: экран учёбы с питомцем-котом и карточкой
Главный экран приложения: экран учёбы с питомцем-котом и карточкой

Четыре месяца назад я начал писать мобильное приложение для изучения английского — называется Kitty Eating. Стек: Angular 21 + Ionic + Capacitor на фронте, ASP.NET Core + PostgreSQL на бэке. Писал я его почти целиком через LLM-агента: сам формулировал задачи, ревьюил, принимал архитектурные решения, но код в редакторе руками набирал редко.

Вот цифры на сегодня:

Метрика

Значение

Коммитов всего

1823

Из них с Co-Authored-By: Claude

607

Период

4 месяца

Строк TypeScript (фронт)

~101 500

Строк C# (бэк)

~99 800

Тестов на фронте (it(...))

2251

Тестов на бэке ([Fact] / [Theory])

1475

Это не «вайб-кодинг на выходных»: приложение реально работает — платные подписки, реклама, пуши, стриминговый LLM-чат, векторный поиск по памяти. Сейчас оно работает на Android; iOS-сборка есть, но в App Store не выложена — выкатывать её я буду, только если проект найдёт аудиторию, и это важная деталь для второй части статьи: значительная доля описанной ниже боли — сугубо андроидная.

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


Часть 1. Главная проблема — не качество кода. Главная проблема — энтропия

За неделю такой работы понимаешь простую вещь: локально LLM пишет хороший код. Возьми любой отдельный коммит — он аккуратный, типизированный, с тестами, с нормальными именами. Проблема начинается на горизонте в двести коммитов.

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

Результат: через месяц у тебя три разных способа поднять модалку, две системы работы с клавиатурой и четыре файла с константами анимаций. По отдельности всё это нормальный код. Вместе — помойка.

Так что весь мой процесс свёлся к борьбе с энтропией. Инструментов у меня два.

1.1. Конституция проекта

В корне репозитория лежит CLAUDE.md — файл, который подгружается в контекст каждой сессии. Это не «стайлгайд». Это набор законов, которые агент физически видит перед каждым действием.

Что там:

  • Правила именования как первоклассный архитектурный артефакт. Дословно: «имя функции обязано меняться при любом изменении её поведения; предпочитать длинное однозначное имя короткому двусмысленному; заметил расхождение имени и ответственности — обязан исправить немедленно».

  • Жёсткие лимиты. Функция длиннее 15 строк = проблема архитектуры, а не повод отключить линтер. any запрещён. /* eslint-disable */ считается нарушением, а не решением.

  • Реактивность. «Всё через сигналы. NgZone не вводить. Существующие NgZone — легаси, мигрировать при касании, не копировать.»

  • Правило .options.ts. Константа используется в одном месте — лежит в файле рядом. В двух и более — переезжает в общий модуль. Магические числа запрещены.

  • И, самое ценное — разделы, написанные постфактум по следам конкретных катастроф. О них ниже.

Фрагмент CLAUDE.md — раздел Quality Gates (защитные слои) и Installed Tools
Фрагмент CLAUDE.md — раздел Quality Gates (защитные слои) и Installed Tools

Правило работает только тогда, когда объясняет «почему». Строчка «не используй CSS-transition для подъёма над клавиатурой» игнорируется агентом при первом же удобном случае. Строчка «не используй CSS-transition, потому что (а) в WebView на Samsung transform-transition не запускается при изменении CSS-переменной и (б) глобальное правило prefers-reduced-motion обнуляет transition-duration, а это позиционирование функциональное, а не декоративное» — не игнорируется никогда.

1.2. Храповики

Правила в текстовом файле — это soft power. Нужен hard power, который нельзя уговорить. У меня четыре слоя:

Слой 1 — pre-push. Husky-хук гоняет node scripts/run-checks.mjs --parallel: линт, сборка, тесты фронта и бэка с порогами покрытия. Красное не уходит на сервер.

Слой 2 — git-guard. Вот это моя любимая паранойя. Проблема: и агент, и человек в 3 часа ночи одинаково любят --no-verify. Поэтому в ~/.git-guard/bin ставится обёртка над git, которая перехватывает и блокирует:

  • git commit/merge/push --no-verify (и короткий -n);

  • HUSKY=0 и HUSKY_SKIP_HOOKS в окружении;

  • git -c core.hooksPath=... и подмену через GIT_CONFIG_KEY_*;

  • git config core.hooksPath ... на запись.

Любая попытка обойти проверки упирается в сообщение вида:

git-guard: отключение хуков через HUSKY=0 / HUSKY_SKIP_HOOKS запрещено.
Обход проверок запрещён: почини тесты или логику и повтори команду.

Звучит как перебор. Не перебор. Первый раз я это написал ровно после того, как обнаружил в истории коммит, проехавший мимо хуков.

Слой 3 — храповик покрытия. В karma.conf.js стоит coverageReporter.check с конкретными числами (сейчас 60% statements, 42.5% branches, 58% functions). На бэке — Threshold в csproj. Правило одно: эти числа можно только поднимать. Не «желательно», а «PR с понижением не мержится».

Слой 4 — spec-храповик. Самый полезный из четырёх, и его я нигде больше не видел, поэтому расскажу подробнее.

Проблема: агент прекрасно пишет тесты, когда его просят. И не пишет, когда не просят. А просить каждый раз — забудешь.

Решение: скрипт check-spec-ratchet.mjs обходит src/app и требует, чтобы у каждого файла с логикой (.service.ts, .component.ts, .controller.ts, .guard.ts, .interceptor.ts, .directive.ts, .pipe.ts, .facade.ts, .lifecycle.ts, .page.ts, .resolver.ts) лежал рядом *.spec.ts.

Понятно, что на живом проекте это сразу даст сотню нарушений. Поэтому существующий долг зафиксирован в spec-ratchet-baseline.json — и baseline может только сокращаться. Новый файл без спеки не пройдёт. Старый файл, получивший спеку, вычищается из baseline и обратно уже не вернётся.

Сейчас в baseline 93 файла — изначально их было заметно больше. Он тает сам собой, потому что каждый раз, когда агент касается файла из списка, я прошу дописать спеку, и долг фиксируется на новом уровне.

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


Часть 2. Клавиатура. 109 коммитов, четыре переписывания с нуля

Теперь про то, где LLM не помогает вообще никак.

Постановка задачи

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

Звучит как задача на полдня. В истории репозитория — 109 коммитов с упоминанием клавиатуры. Реальная цифра больше: часть коммитов называется просто «update keyboard».

Хроника падений

Вот реальная последовательность из git log, слегка сжатая:

20–23 мая. Подход №1: сжимать высоту.

Идея была в том, чтобы ужимать сам layout под клавиатуру:

Fix mobile keyboard layout behavior
feat: animate layout height change when keyboard opens/closes
chore: reduce keyboard layout transition to 200ms
fix: rework PWA keyboard — padding approach, keep keyboard between cards
fix: animate page-split height shrink when keyboard opens

Пять итераций «а давай уменьшим высоту контейнера» / «а давай добавим padding». Умерло, потому что изменение высоты вызывает reflow всего поддерева в каждом кадре анимации. На флагмане незаметно, на бюджетном Android — слайдшоу.

23 мая. Подход №2: transform.

Следующая попытка — высоту не трогать вообще, а сдвигать содержимое целиком:

feat: add KeyboardTransformService with height resolution and transform logic
refactor: replace height-shrink keyboard CSS with transform
refactor: remove KeyboardFocusGuardDirective from all pages
test: update keyboard contracts for transform approach

Правильное направление (transform не вызывает reflow), но логика размазалась по трём сервисам и директиве на каждой странице.

24 мая. Подход №3: единый оркестратор. Один день, двадцать коммитов:

feat(keyboard): add KeyboardOrchestratorService as unified keyboard manager
refactor(keyboard): migrate all consumers from KeyboardVisibilityService
refactor(keyboard): remove old KeyboardVisibilityService, KeyboardTransformService, KeyboardEmulatorService
fix(keyboard): fix rAF race conditions in emulator hide-show cycle
fix(keyboard): run event listeners outside Angular zone to prevent CD-induced focus loss
fix(keyboard): guard against CD-induced focus loss during keyboard activation
fix(keyboard): defer signal updates to prevent CD from destroying focused input
fix(keyboard): use native rAF for signal flush, remove duplicate focus listeners
fix(keyboard): restore focus after Angular CD-induced blur
fix(keyboard): remove aggressive focus restoration that prevents keyboard dismiss on iOS

Шесть коммитов подряд про потерю фокуса — каждый следующий чинит регрессию от предыдущего. Сначала уводим слушатели из зоны Angular, потом откладываем обновление сигналов, потом восстанавливаем фокус после blur, а потом убираем это восстановление, потому что на iOS клавиатура перестала закрываться.

Именно после этой серии в CLAUDE.md появился запрет на NgZone в новом коде.

Дальше — июнь и июль. «keyboard V_0.2», «keyboard V_0.3», ещё десятки фиксов, регрессия на регрессии.

Настоящие причины

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

Причина 1. Плагин молча ломал главное допущение.

Вся моя модель строилась на том, что WebView не меняет размер под клавиатурой: adjustNothing в манифесте, KeyboardResize.None в Capacitor, вьюпорт всегда полной высоты, я двигаю содержимое сам.

Оказалось: плагин @capacitor-community/safe-area в своём setupSafeAreaInsets добавляет DecorView отступ размером с клавиатуру. Без опции отключения. Без единого лога.

То есть на каждом открытии клавиатуры WebView сжимался и вся страница перекладывалась в один кадр. На устройстве это выглядело как полный прыжок страницы плюс залипания на 26–51 мс.

Лечится только тем, что в MainActivity после super.onCreate() переустанавливается свой listener на тот же DecorView (последний победил), который сохраняет контракт плагина по safe-area, но никогда не паддит под IME.

Чтением кода такое не находится. Только замером window.innerHeight до и после открытия клавиатуры.

Причина 2. Samsung WebView не анимирует transform от CSS-переменной.

transform: translateY(calc(-1 * var(--kitty-keyboard-inset)));
transition: transform 300ms;

Меняем --kitty-keyboard-inset — плита телепортируется за один кадр. Transition не запускается вообще. При этом transition на opacity, переключаемый классом на том же элементе, работает прекрасно.

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

Причина 3. prefers-reduced-motion убивал функциональную анимацию.

В _a11y.scss у меня, как у приличного человека, лежит глобальное правило, обнуляющее transition-duration при включённом «уменьшить движение». Правильное правило.

Только вот подъём над клавиатурой — это не декорация. Это позиционирование. Обнулив ему длительность, ты не «убираешь красивость», ты ломаешь механику.

Теперь это записано в конституции: позиционирование, привязанное к клавиатуре, делается через WAAPI (element.animate()) с явным таймингом, потому что оба вышеописанных киллера — и квирк Samsung, и глобальный reduced-motion — до WAAPI не дотягиваются.

Причина 4. Ленивая растеризация Chromium.

Шторка чата монтировалась в DOM в момент открытия. Chromium растеризует свежесмонтированное поддерево лениво — и делает это ровно во время подъёма. Фризы 42–126 мс в самом заметном месте анимации.

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

Причина 5. Два аниматора на одном ящике.

У меня есть директива, анимирующая высоту контейнера через WAAPI по ResizeObserver. И у одного из дочерних элементов внутри висел CSS-transition на padding.

CSS-переход скармливал ResizeObserver’у промежуточные значения каждый кадр, директива на каждый сэмпл перезапускала свою анимацию, та меняла размер, ResizeObserver срабатывал снова. Два аниматора гонялись друг за другом до бесконечности. Внешне — «контент прыгает над клавиатурой».

Что из этого следует про LLM

LLM не может отладить то, что не воспроизводится в коде.

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

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

Помогло вот что.

  1. Записать инварианты, а не решения. В FrontEnd/CLAUDE.md теперь есть раздел «Keyboard Motion Invariants», начинающийся словами: «жёсткие правила для ЛЮБОГО изменения, которое рендерит или анимирует рядом с клавиатурой — каждая клавиатурная регрессия за всё время трассируется до нарушения одного из этих пунктов». Пять пунктов, каждый с обоснованием и с указанием, какой именно баг он предотвращает.

  2. Ввести обязательный ритуал верификации. Дословно из конституции: «изменение рядом с клавиатурой уходит в релиз только после записи открытия/закрытия на реальном устройстве и покадрового просмотра (~30fps), в дополнение к профильным тестам. Плавность нельзя оценить по код-ревью или глазами на полной скорости».

  3. Дать агенту рецепты замеров. Не «проверь, что работает», а конкретные команды: как снять таймлайн через CDP, как проверить, что window.innerHeight не меняется при открытии IME, какие спеки гонять.

Итоговая подсистема — 22 файла и ~11 900 строк на фронте плюс 625 строк нативного моста в MainActivity.java. На «подвинуть шторку вверх». И я не считаю, что перестарался.


Часть 3. Память питомца, или как «забудь» оказалось сложнее, чем весь алгоритм интервальных повторений

Второй большой узел, который LLM не вывозит по другой причине.

Кот в приложении разговаривает с пользователем через LLM и должен его помнить между сессиями. Первая версия была наивной: подкладываем в промпт последние N сообщений и сгенерированное саммари.

Работает ровно до первого «а помнишь, я говорил про собеседование?» через две недели.

Диалог с котом-питомцем: на вопрос, помнит ли он, чем я занимаюсь, кот отвечает, что я разработчик и делаю приложения
Диалог с котом-питомцем: на вопрос, помнит ли он, чем я занимаюсь, кот отвечает, что я разработчик и делаю приложения

Итерация 1: LLM-роутер поиска. Провал

Первая «умная» версия: перед каждым ответом делаем дополнительный вызов модели, который решает, какие воспоминания достать. То есть LLM выбирает, что LLM должна вспомнить.

Умерло по трём причинам: удвоенная задержка, удвоенный счёт за токены, и — главное — недетерминированность. На один и тот же вопрос роутер доставал разные воспоминания. Отладить невозможно.

Итерация 2: pgvector + локальные эмбеддинги

Заменил на нормальный семантический поиск: pgvector в PostgreSQL, эмбеддинги считаются локально моделью e5 через ONNX Runtime прямо в процессе бэкенда. Никаких обращений к внешнему провайдеру ради эмбеддингов, никакой утечки пользовательских данных, результат детерминированный.

Память разложилась на слои с явным приоритетом:

Слой

Что хранит

Рабочая память

ограниченный хвост диалога

Канонические факты

текущие факты/предпочтения/цели + свидетельства

Открытые петли

неразрешённые обещания и вопросы

Эпизодическая память

конкретные ходы диалога

Саммари

сжатая непрерывность

Живой портрет

ТЫ / Я / МЫ + настроение

Реестр подавления

долговременные барьеры на чтение

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

  1. явная поправка в текущей реплике пользователя;

  2. активное подавление/отзыв/замещение;

  3. текущий канонический факт с активным свидетельством;

  4. текущее утверждение пользователя в эпизоде;

  5. производная проекция с ещё живым свидетельством;

  6. то, что раньше сказал сам ассистент — это только контекст разговора, а не истина.

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

Диаграмма слоёв памяти кота (рабочая память, канонические факты, саммари, реестр подавления и др.) и порядок авторитетности источников
Диаграмма слоёв памяти кота (рабочая память, канонические факты, саммари, реестр подавления и др.) и порядок авторитетности источников

Итерация 3: а теперь сделай «забудь» честным

Вот здесь начинается настоящая работа, и здесь же LLM ломается второй раз — по причине, противоположной клавиатурной.

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

Пользователь тапает «забудь это» на заметке. Наивная реализация — DELETE FROM PetMemoryNotes WHERE Id = @id. Такой код пройдёт и ревью, и тесты. Работать он при этом не будет, потому что факт к этому моменту уже:

  • переварен в сгенерированное саммари;

  • отражён в «живом портрете»;

  • лежит в основе уже сгенерированной домашки;

  • закэширован на стороне провайдера;

  • и прямо сейчас находится в контексте трёх параллельных стриминговых запросов.

Удалил строку — на следующем обновлении саммари факт воскреснет из производных данных. Пользователь получит от кота «а помнишь, ты говорил…» про то, что он час назад попросил забыть. Это не баг рендеринга, это нарушение обещания.

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

UI приложения: заметка кота с прогрессом урока и зелёная кнопка «FORGET» («забудь это») для удаления факта
UI приложения: заметка кота с прогрессом урока и зелёная кнопка «FORGET» («забудь это») для удаления факта

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

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

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

История, которая всё объясняет

В какой-то момент вторую версию памяти реализовывал параллельный контур. Пришёл большой, аккуратный, зелёный по тестам коммит.

При аудите выяснилось: операция «забыть одну заметку» стирала весь чат пользователя целиком. Логика была написана так, что при определённом пути очистка уходила по всей истории. Тесты этого не ловили, потому что проверяли, что целевая заметка удалена — и она удалялась.

Иллюстрация лучше не придумаешь. Тесты, написанные LLM по спецификации, проверяют ровно то, что в спецификации написано, и ничего сверх. Если ты не написал «и при этом не должно удалиться ничего лишнего» — этого теста не будет.

С тех пор у меня в спецификации памяти есть отдельный раздел «Non-negotiable invariants» и явное правило: зелёная сборка не делает подсистему готовой к релизу. Каждый релизный гейт должен пройти в целевом окружении.


Часть 4. Что LLM делает хорошо (чтобы не выглядело как нытьё)

Без всего этого схема бы просто не окупалась.

  • CRUD и слои данных. Контроллер + сервис + DTO + миграция + тесты — идеально, быстро, без ошибок.

  • Тесты. 3726 тестов на двух платформах я бы руками не написал никогда. Причём тесты хорошие, с граничными случаями.

  • Механический рефакторинг. «Переименуй эту концепцию везде», «вынеси эти константы», «разбей этот сервис на три» — безупречно и за минуты.

  • Документация. Спецификации в DOCS/ на сотни строк, актуальные, с таблицами. Их писала модель, я правил.

  • Незнакомые API. RevenueCat, AdMob, Firebase HTTP v1, QuestPDF, ONNX Runtime — везде рабочий код с первого-второго раза.

  • Языковая рутина. i18n-каталоги, генерация окружений, разведение сборки на несколько языковых пар из одной кодовой базы.

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

Как это выглядит целиком, в живом приложении:


Часть 5. Практические выводы

1. Пиши правила с обоснованием. «Не делай X» игнорируется. «Не делай X, потому что в 2026-07 это дало вот такой баг на устройстве» — не игнорируется.

2. Автоматизируй запрет, а не договорённость. Всё, что можно обойти, будет обойдено — не по злому умыслу, а потому что в контексте следующей сессии договорённости нет. git-guard, храповики покрытия, spec-храповик, обязательный CI-гейт перед деплоем.

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

4. Для всего, что связано с приватностью и удалением, пиши инварианты до кода. Не «удали заметку», а «после этой операции факт не может всплыть ни через саммари, ни через портрет, ни через производные, ни через гонку с параллельным стримом». Разница в объёме кода — раз в десять.

5. Веди память между сессиями сам. У меня отдельный каталог заметок, который подгружается в начало каждой сессии: принятые решения, отклонённые подходы, специфика окружения. Без него каждая новая сессия начинает предлагать то, что мы уже отвергли месяц назад.

6. Одно правило важнее всех остальных: «оставь код лучше, чем нашёл». У меня в конституции написано «MVP закончен — технический долг с этого момента не допускается, “починю потом” неприемлемо, потому что “потом” в этой кодовой базе не наступает». Звучит пафосно. Но это единственная формулировка, которая заставляет агента не проходить мимо кривизны рядом с задачей.


Заключение

Схема «человек-архитектор + LLM-исполнитель» работает и даёт огромное ускорение. Но работает она не сама по себе: качество тут удерживает не намерение, а конструкция вокруг.

Два места, где она ломается, я бы назвал так:

  • Физическая реальность. Всё, что зависит от того, как конкретный движок на конкретном устройстве раскладывает конкретный кадр. Здесь агент генерирует правдоподобные гипотезы бесконечно, и цикл разрывается только замером.

  • Невысказанные инварианты. Всё, что должно быть верно после операции, но не описано в задаче. Здесь агент напишет корректный код для неправильно понятой задачи, и тесты это подтвердят.

Клавиатура и «забудь» — по представителю из каждой категории: сто девять коммитов и спецификация на тридцать страниц.

Оно того стоило. Но если бы я знал это в апреле, я бы начал не с кода, а с храповиков.


Пока существует только Android-сборка: iOS-версия собирается из той же кодовой базы и работает, но в App Store не выкладывалась — это решение продуктовое, а не техническое.

Готов подробнее раскрыть любой узел: нативный мост клавиатуры и почему WindowInsetsAnimationCompat пришлось перехватывать после Capacitor, слоистую память с pgvector и локальным ONNX-эмбеддером, устройство spec-храповика или квоты на LLM-вызовы. Продуктовая сторона — механики, кот-тамагочи, аудио-сериал вместо домашки — вынесена в отдельную статью, здесь она была бы не по адресу.