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

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

1. У пофазной проверки появился свой уровень

У навыка dev было одно правило на все фазы: сборка, тесты, clippy и fmt-check — все четыре зелёные, прежде чем фаза уйдёт в коммит. В планах от 2 до 11 фаз (медиана последних восемнадцати закрытых — 6), так что в обычном плане эта проверка прогонялась пять-девять раз.

Измерения показали, что почти всё её время уходило на тесты, которым незачем запускаться так часто. cargo nextest list --workspace насчитывал 1212 тестов. Двадцать семь из них — прогоны на GPU, где один тест пропускает через реальный адаптер каждый поставляемый пресет или каждую сцену, — съедали бо́льшую часть времени. Весь набор шёл 341 секунду, из них 126 — один только reactivity. Получается, что 2,2 % тестов съедали бо́льшую часть проверки, а сама проверка повторялась до девяти раз за план — причём это ровно те 2,2 %, на которые отдельно взятая фаза влияет с наименьшей вероятностью.

Исключение к тому моменту уже было написано. Оно лежало в .githooks/pre-push, было побайтово скопировано в CI и отсутствовало ровно там, где запускалось чаще всего: инструкции навыка dev не предлагали суженной формы и вообще не сообщали, что сужать позволено. Более раннее решение разнесло тесты по пяти уровням по виду и указало, когда их гонять, ровно для двух моментов — pre-push и CI. Пофазный момент, который повторяется пять-девять раз за план, своего уровня так и не получил.

ADR-0156 его выдал: пофазная проверка сужается, а весь набор причитается один раз за план.

Перенимать здесь стоит не само сужение, а то, что случилось со списком. Девять исключённых наборов больше нигде не перечислены — ни в хуке, ни в CI, ни в инструкциях навыка. Теперь это профиль fast в .config/nextest.toml, и все три места ссылаются на -P fast. Измеренное обоснование осталось в шапке хука и в toml намеренно не скопировано; переехал только список. Добавить или убрать набор — теперь правка одного файла, и отстать от неё никто из троих уже не может.

Масштаб задают два числа. Исключение этих наборов сократило хук с ~98 до ~27 секунд на прогретом кэше. Затем более позднее решение вернуло по трём из них выборку из 24 пресетов ценой +58,5 секунды — что выглядит как отданный назад выигрыш, но им не является: до этой выборки пофазный уровень не отрисовывал библиотеку пресетов вообще. Это купленное покрытие, и заплатили за него из бюджета, который создало первое изменение.

Что хук отказывается запускать и фраза, которая это объясняет

Pre-push-хук — то место, где эта дисциплина виднее всего, потому что это проверка, которую чаще всех оплачивает человек, а не CI.

Четыре вещи исключены из него намеренно: аудит цепочки поставок, doc-тесты, Miri над небезопасным кольцевым буфером и прогон покрытия. Все четыре — настоящие проверки, и все четыре идут в CI. Ни одна не идёт на push, потому что они растягивают хук с десятков секунд до минут, а

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

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

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

Переехавший список — и вот в нём настоящий урок

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

Было так: девять исключённых наборов названы в pre-push-хуке, побайтово скопированы в workflow CI и полностью отсутствуют в инструкциях навыка. Три потребителя, две копии, один пропуск. Интересен именно пропуск: никто не спорил о том, какие наборы пропускать, — просто одному из трёх мест, которому список был нужен, его так и не выдали, и не было механизма, которым это можно было бы заметить.

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

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

Три момента, три бюджета

Если отойти на шаг, изменение оказывается меньше, чем «мы ускорили тесты», и полезнее.

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

Исходное разложение по уровням отсортировало тесты по виду, а затем назначило их только двум моментам из трёх. Пофазному моменту — самому частому и тому единственному, где ценой является сидящий и ждущий человек, — уровня не досталось вовсе, поэтому он по умолчанию унаследовал уровень push’а. Назвать момент — и есть вся починка; сужение следует из этого механически.

2. Навык заводится решением, а не расширением dev

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

Навык dev, который всё это реализует, определён как «весь код — Rust (ядро + отдельное приложение) и C++ (плагин foobar)», и каждое его правило выросло из этого: аудио-колбэк, прагма горячего пути, cargo nextest -P fast, C ABI. Ничто из перечисленного не применимо ни к процессу-рендереру, ни к preload-мосту, ни к Content Security Policy. А те правила, которые там как раз применимы, — те самые, что не дают Electron-приложению обзавестись CVE, — нигде в репозитории записаны не были.

Поэтому студия получила собственный навык studio-builder по ADR-0177. Довод оттуда я бы сохранил:

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

Это и есть весь аргумент против удобства. Расширить dev в моменте ничего не стоит и незаметно лишает вас рецензента.

Прецедент задан явно, и он важнее самого случая. Когда пресеты стали третьим типом артефакта, они получили preset-author, а не расширенный dev. Навык заводится тогда, когда артефакт действительно другой, и граница его записывается. Дважды подряд это было решением с записью, а не самотёком.

Пять навыков и то, чего каждому трогать нельзя

Для конкретности: в Ritmolux их теперь пять. architect проектирует и рецензирует, dev строит Rust и C++, preset-author владеет библиотекой пресетов, studio-builder владеет новой студией на Electron, а skill-creator — тот самый, взятый со стороны, — существует, чтобы писать остальные.

Границы заданы исключениями, а не территориями, и поначалу это выглядит странно — пока не станет ясно зачем. studio-builder никогда не трогает движок. preset-author пишет пресеты и не правит системы, которыми эти пресеты управляют. dev реализует фазы плана и не решает, какими будут фазы. Исключение проверяемо на ревью — тронуло ли это изменение файл за пределами навыка? — тогда как территория остаётся предметом толкования.

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

3. Worktree переехали внутрь репозитория, и менять почти ничего не пришлось

Каждый план работает в отдельном git-worktree. Исходное решение отправляло их в WORK/rlx-plan-NNNN — рядом с репозиторием, за его пределами, — и весь инструментарий писался под эту форму, где обход от корня в принципе не может дотянуться до соседнего worktree.

Теперь инструментарий открывает их внутри репозитория. 10 сентября три из них были живы сразу в двух формах: два снаружи и один в .claude/worktrees/plan-0161-structural-hold, привязанный к сессии по pid. В ADR-0182 это сформулировано точно:

Внутренняя форма — не ошибка, которую надо исправить: именно так инструментарий и создаёт worktree, а соглашение, которое инструментарий по умолчанию нарушает, соглашением не является.

Оказалось, что подстраиваться под вложенную копию почти не пришлось, — и по причине, найденной годами раньше совсем для другого. Три проверки перечисляют свои входы через git ls-files, а не обходом файловой системы, и откатываются к обходу, только если git не отвечает; каждая при этом сообщает, чем воспользовалась. Причину тогда назвали такую: паритет с CI, потому что обход файловой системы не отличит наши собственные документы от README чужой зависимости, который лежит в .gitignore — локально есть, в свежем клоне CI нет.

Вложенный worktree — ровно такой случай: локально есть, в CI нет, под контролем версий не числится. Перечисление по отслеживаемым файлам отсекло его само собой.

Ровно одна проверка это соглашение так и не переняла. check-index-rows.mjs обходит файловую систему безусловно, поэтому она прочитала намеренно битую фикстуру внутри вложенной копии — ту, которая обязана падать, — и упала. Одна проверка выбилась из общего правила, и нашло её изменение, а не читатель. Это ровно тот исход, которого и хочется.

Зачем плану вообще отдельный worktree

Абзац для тех, кто так не делал, потому что причина здесь не отвлечённая «изоляция».

План идёт фазами, каждая уезжает своим коммитом, а церемония закрытия делает git mv завершённого плана из plans/ в plans/done/. Именно этот переезд и окупает worktree: одновременно в работе может быть несколько планов, у каждого свой checkout, каждый может собираться и гонять свои тесты, не имея в дереве чужого недоделанного состояния. Сессию над планом A и сессию над планом B разделяет не один git stash — они действительно лежат в разных деревьях.

Цена в том, что каждому инструменту репозитория теперь приходится отвечать на вопрос «в каком я checkout’е?» — на который, как выяснилось, ADR-0182 отвечать и не понадобилось, потому что инструменты спрашивают git, а не файловую систему. Дисциплина окупилась там, куда её никто не нацеливал.

4. Чужой навык носит с собой хеш

Мелочь, но она закрывает настоящий пробел. Один из навыков в Ritmolux написан не здесь: skill-creator взят из anthropics/skills. Репозиторий фиксирует его в skills-lock.json:

{
  "version": 1,
  "skills": {
    "skill-creator": {
      "source": "anthropics/skills",
      "sourceType": "github",
      "skillPath": "skills/skill-creator/SKILL.md",
      "computedHash": "7e3c9cd74e9e2b4828527a857170e86310f2dab5ea8030a9043df2c7e6c88857"
    }
  }
}

Скопированный к себе файл без указания происхождения ничем не отличается от файла, который кто-то написал и забыл. Источник, путь и SHA-256 превращают происхождение копии в проверяемый факт вместо воспоминания. Довод тот же, что и для любой другой зафиксированной зависимости, только применённый к промпту.

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

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

5. Пересадка заняла один день

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

9 сентября я начал piano-tutor: десктопный тренажёр фортепиано под Windows для Yamaha CK88 по USB MIDI, который показывает то, что вы играете, пока вы это играете. Примерно за сутки в нём появились 5 ADR, 3 плана по фазам, два навыка, pre-push-проверка и ходячий скелет, который к тому моменту уже вставал на ноги. Всего 17 коммитов. О происхождении README говорит без церемоний: «обвязка на основе планов, адаптированная из проекта Ritmolux».

Пересаженное приехало целым: решения — в ADR, работа — в планах по фазам, навык architect, который проектирует, и навык dev, который строит, ревью в свежей сессии на закрытии каждого плана и одно правило коммита, закреплённое хуком: добавлять файлы явными путями, никогда git add -A.

Интереснее другое — то, что пересадить не вышло, потому что предметная область этого не приняла.

Мешало железо

Задача ставилась так: разработка должна идти без присмотра — план за планом, ночью, а человек подключается только там, где без него правда не обойтись. Мешала не обвязка. Мешало то, что конвейер, ради которого это приложение и существует, начинается с куска железа: он либо подключён, либо нет.

Причём зависимость стала несущей сразу же. Фаза 2 плана 0001 доказывает требование «без потери событий» «записанным плотным дублем не менее чем из 2 000 событий». Этот дубль нужно записать на инструменте раньше, чем сможет пройти фаза, которой он нужен, — а значит, человек оказывается в середине ходячего скелета, а не в его конце. У фикстур для определения тональности в фазе 3 всё устроено так же. Каждый следующий план повторяет тот же узор: выравниванию по нотам нужен дубль, чтобы было что выравнивать; тренеру — чтобы было что разбирать; генератору упражнений — чтобы было что оценивать.

Ответ дан в ADR-0004: приложение играет само. Сборка из исходников показывает рядом с аппаратными портами группу виртуальных — virtual:c-major-scale, virtual:ii-V-I-in-F, virtual:a-minor-arpeggios, virtual:dense-2000. Откройте любой, и сгенерированный по фиксированному зерну пассаж пойдёт по тому же самому пути разбора, записи и отрисовки, по которому идёт игра на настоящем инструменте.

Две вещи в том, как принималось это решение, важнее самой функции.

Оно сформулировано как третья реализация уже существующего шва, а не как новая архитектура. MIDI и так был спрятан за интерфейсом MidiSource — именно затем, чтобы транспорт можно было заменить, — и вторая реализация, ReplaySource, уже существовала, по принципу «воспроизведение не должно быть вторым путём в коде». Фразу из этого ADR я ещё не раз повторю: шов с тремя реализациями — свидетельство того, что шов выбран верно, куда более веское, чем шов с одной.

Есть и третья вещь, тише двух предыдущих, — про то, чем виртуальные порты не являются. Они не мок. Сгенерированный пассаж идёт по тому же самому пути разбора, записи и отрисовки, что и настоящий инструмент: тот же интерфейс MidiSource, то же декодирование сообщений, та же отрисовка. Синтезируется источник байтов, и больше ничего. Мок заменил бы собой то, что проверяется; вторая реализация транспорта оставляет проверяемое целиком нетронутым и подменяет только то, что его кормит.

Очевидный путь отвергли по соображениям безопасности, а не вкуса. Отдельный IPC-канал «только для разработки», который тестовая обвязка дёргает, чтобы протолкнуть события, добавил бы ещё одну возможность в window.api и достижимый из рендерера канал в главный процесс, а защищал бы его один лишь флаг времени сборки. И всё это — против оболочки, которая держится на contextIsolation, sandbox, nodeIntegration: false, двойном CSP и одной узкой возможности на каждую потребность. Вердикт ADR: «это есть только в разработке» — довод, который предшествует большинству CVE в Electron.

Поэтому флаг решает в обе стороны. PT_HARNESS=1 показывает виртуальные порты в любой сборке, а PT_HARNESS=0 прячет их даже при запуске из исходников — именно так сквозной набор тестов доказывает, что проверка настоящая, а не что обвязка просто существует.

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

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

Что на самом деле дал первый день

Стоит уточнить, что значит «обвязка, пересаженная за день», потому что эту формулировку легко и переоценить, и недооценить.

К его концу были пять ADR, три плана по фазам, два навыка, pre-push-проверка, хук, требующий явных путей в коммите, и ходячий скелет, который к тому моменту уже вставал на ноги. Семнадцать коммитов. Чего не было — так это приложения, которым кто-нибудь захотел бы пользоваться: скелет показывает клавиатуру на экране и больше ничего.

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

Самый полезный артефакт здесь — собственная таблица ответов интервью в README. Она фиксирует то, что решили за один присест: платформа, подключение, функции первого месяца, репертуар, как тренер добирается до модели, стек, что показывать на экране, локальность данных, момент запуска тренера, источники нот, порядок сборки. Одиннадцать вопросов с одиннадцатью ответами, датированные, в самом верху репозитория. Всякий, кто придёт позже, — включая свежую сессию через час, — прочитает ограничения раньше кода.

Где сейчас все три

market-analyzerRitmoluxpiano-tutor
Возрастс маясемь недельдва дня
ADR1091835
Завершённых планов111154 (+14 в работе)ходячий скелет
Навыков952
ЯзыкPythonRust + C++TypeScript

Эти числа не табло — план может быть на одну фазу и на одиннадцать, а ADR на абзац и на четыре страницы, — но форма информативна. У Ritmolux ADR больше, чем завершённых планов: так выглядит проект, который ещё многое решает. У market-analyzer планов чуть больше, чем записей: так выглядит проект, который в основном исполняет. У piano-tutor пять ADR против трёх планов и ни одной завершённой работы: так выглядит второй день.

market-analyzer с июля прошёл путь с v0.9.0 до v0.26.0 и предоставляет теперь 59 MCP-инструментов. У Ritmolux рядом с планами лежат ещё четыре поведенческие спецификации, и о нём есть отдельный пост.

Что не изменилось

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

Сердцевина того, что я описывал в июле, не тронута. Планы по-прежнему говорят что и одноразовы; ADR по-прежнему говорят почему и только дописываются. Фаза плана по-прежнему уезжает одним коммитом с проверяемым «сделано, когда». Навыки по-прежнему ждут при пустом вызове, а не сканируют репозиторий, угадывая намерение. Ревью по-прежнему идёт в свежей сессии на закрытии плана, и делает его не тот, кто писал код.

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

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

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