Уроки
То, что этот плагин узнал дорогой ценой. Каждый пункт — решение или отказ, сформировавший код, записанный так, чтобы следующее изменение его не повторило. Дизайн, на который они ссылаются, — в memory-model.ru.md.
Что здесь есть и чего здесь нет. Это уроки про floppy — читателю, который им не пользуется, они бесполезны. Урок, оказавшийся верным независимо от floppy, принадлежит базе знаний (knowledge/README.md), у которой есть контракт проверки, а у этого файла его нет: дата, среда и способ перепроверить. Два урока переехали туда 2026-09-05 — find, не идущий за символической ссылкой в корне, и сюита тестов, зависающая на незакрытом stdin. Ни один из них не был про этот плагин; оба были про почву, на которой он стоит.
Переименование области в живой памяти стоит миграции
2026-08-25 раскладку памяти переименовали четыре раза за один день: projects/<key> → projects/<key>/memory, /local → /shared, /private → public/projects/<key> и private/projects/<key>. Каждое имя исправляло предыдущее, и каждое выводилось из положения дел, а не из записанной модели.
Какая версия что принесла: до 0.5.0 двумя областями были projects/<key>/memory и сам projects/<key>, и второй содержал первый всякий раз, когда один репозиторий играл обе роли, так что приватные заметки и общая память оказывались в одном дереве. В 0.5.0 и 0.5.1 второй назывался projects/<key>/local; в 0.6.0 его переименовали в private, потому что прежнее имя говорит «локально для машины», а область — ничего подобного. В 0.7.0 аудиторию вынесли в каталог пространства имён наверху и убрали лист, её повторявший, — это и есть раскладка, которую описывает memory-model.ru.md.
Причина: каждое имя склеивало две независимые оси. local говорил «про эту машину», а значил «приватно для проекта, общо между машинами». workplace_repo называл аудиторию словом с оси «где это верно». Пока модель существовала только в голове того, кто правит, каждое следующее имя ошибалось в ту же сторону.
Чего это стоит. Переименование в живой памяти — не правка строки, а миграция: git mv в репозитории памяти, перенаправленный симлинк на каждой машине, переписанные указатели индексов (три указателя остались битыми на сутки — git mv переименовал каталог, но не текст, который на него ссылался) и описание раскладки, поправленное в двух README и в документации потребляющего проекта. Добавьте окно, в котором машины расходятся: одна пишет по старому пути, пока другая читает по новому, и память тихо форкается.
Правило: запиши модель прежде, чем что-либо переименовывать в живой памяти — какие есть оси, какая ячейка чем выражается — и дай человеку её прочитать. Написание memory-model.md после четвёртого переименования немедленно поймало пятую ошибку: её первый черновик разрешал пространство имён в корне репозитория, а при одном URL, играющем обе роли, области бы столкнулись.
Что сработало на практике: глагол, который отказывается работать на старой раскладке и печатает git mv, который надо выполнить, вместо того чтобы мигрировать самому. Мигрировать самому — это форк на другой машине; напечатать команду дёшево. Ссылки глагол всё же чинит: за ними нет содержимого.
Складывание вызовов убрало те ходы, на которые целилось, а wrap всё равно удвоился
Измерено 2026-09-05 на 48 прогонах /wrap в двух потребительских репозиториях, с классификацией каждого хода ассистента по использованным инструментам, — и в тот же день пересчитано, потому что первый проход считал не ту единицу. Ход — это один запрос к модели; в JSONL это несколько записей с общим requestId и одним блоком usage, так что текст, рассуждение и вызов инструмента из одного ответа — три записи и один оплаченный ход. Счёт по записям завышает в 1,6 раза. Всё ниже — по requestId, со счётом по записям рядом, потому что первая версия этого урока вышла именно с ними.
| медиана ходов на прогон wrap | по requestId | по записям транскрипта |
|---|---|---|
| до 2026-08-25 | 12 | 20 |
| после | 25 | 41 |
| другой потребитель, после | 29 | 49 |
Что пережило пересчёт — сам урок, и он никогда не зависел от единицы. Складывание десяти команд в check и commit сделало то, ради чего затевалось: вызовы до-плагинных tools/*.sh были 29% ходов до 2026-08-25 и нулём после, — а общее число всё равно удвоилось в той единице, которая оплачивается. Не по вине складывания: в тот же день приехало многое, память переехала во второй репозиторий, файл состояния отделился от журнала, а индексы стали деревом. Wrap приобрёл работу, а не сбросил её.
Что пересчёт опроверг, оба раза в сторону «дешёвое оказалось не дешёвым»:
- «Глаголы, заменившие скрипты, — 3,6% всех ходов, значит механическая половина wrap почти бесплатна». В оплачиваемой единице глаголы floppy — это 5,1 хода на прогон, 23% всех ходов, крупнейшая категория, впереди правок заметок (3,9), прочего
Bash(4,0) и правок файла состояния (2,6). Примерно половина из этого избыточна: прогоны зовутlint,guardиstatusповерхcheck, который все три уже выполнил. Один прогон записалcheck×4 иlint×3. - «17% ходов вообще не зовут инструмент — это повествование, самый дешёвый ход для удаления». В оплачиваемой единице это 5,6% (34 из 608), и все 34 — последний ход прогона: отчёт человеку. В середине прогона их нет. Из 389 записей без вызова инструмента 267 не несут и текста — это блоки рассуждений ходов, уже посчитанных. Резать там нечего.
Пересчёт не затронул: делегирование механической половины wrap субагенту по-прежнему не окупается. Отправить его и прочитать отчёт — два хода в большом окне в обмен на два хода, печатающих почти ничего; это довод про арифметику ходов, а не про размер механической половины, поэтому поправка с 3,6% на 23% его не воскрешает.
Отсюда три следствия:
- считать надо ходы, а ход — это
requestId, а не строка транскрипта. «Мы сложили десять вызовов в два» было правдой и не сделало wrap дешевле, потому что общее число потом никто не перемерил. Складывание, попавшее в свою цель при удвоившемся итоге, со стороны счёта неотличимо от отсутствия складывания — но только когда итог измеряют в оплачиваемой единице; - сокращать надо глаголы сверх
check, а не повествование.checkуже выполняетlint,guardиstatus; скилл, зовущий их снова, платит за каждый. Независимые вызовы принадлежат одному блоку, а файл состояния следует писать один раз, а не чинить построчно; - записанная ловушка не защищает того, кто считает в обход починенного инструмента. Удвоение по записям в 1,6 раза было уже известно и уже исправлено — в собственном скрипте подсчёта у потребителя, неделями раньше. Первый проход здесь вывел баг заново, ad-hoc скриптом, в котором этой починки не было.
Выводимое состояние лучше флага в конфиге
При проектировании раскладки «память вне репозитория с кодом» напрашивался булев ключ в конфиге: external = yes/no. Его отвергли. Режим выводится из того, куда разрешается путь каталога памяти.
Флаг был бы вторым источником истины о том, что файловая система уже знает, и эти двое разошлись бы ровно в опасном случае: симлинк не создан, конфиг по-прежнему говорит «внешняя», записи ложатся в обычный каталог внутри репозитория с кодом, а правило игнорирования прячет их там. Разрешение пути не может ошибиться в том, куда пойдёт запись.
И это не умозрительно: вывод пришёл на место пары ключей конфигурации, которые уже успели столкнуться. До версии 0.4.2 у store и у workplace был свой ключ каталога, и оба ключа имели одно и то же значение по умолчанию. Если вы задавали оба ключа, они давали один каталог. Никакого сообщения об этом не было. Измерено 2026-08-25 в этом состоянии:
- Первый глагол клонировал свой репозиторий в каталог.
- Второй глагол находил там каталог
.gitи не клонировал. - Второй глагол не сравнивал удалённый с настроенным URL.
- Второй глагол сообщал «ok a write through the link lands in the workplace repository».
- Заметки уходили при этом в репозиторий хранилища.
commitзапушил бы их туда.
Красным не горело нигде. Состояние убрало то, что каталог перестали спрашивать у конфигурации: он выводится из URL, и потому два URL не могут дать один каталог. Вместе с выводом пришёл и страж — для состояния, которого не выражает ни один корректный режим: чекаут уже лежит по этому пути, а его origin не тот, что настроен.
Как это применять:
- если состояние читается из мира (путь, симлинк, наличие файла, версия бинарника) — читай его, не спрашивай конфиг. Конфиг несёт то, чего у мира нет: адрес хранилища, имя области, потолок;
- «выводится» не отменяет необходимости в страже. Каждой ветке вывода нужен свой — для состояния, которого не выражает ни один корректный режим. Здесь это «каталог памяти игнорируется и лежит внутри репозитория с кодом»: сломано при любой раскладке и в точности имеет форму недоделанной настройки;
- вывод должен быть дешёвым и переносимым. Здесь это
cd && pwd -P, потому чтоrealpathиreadlink -fна macOS — не те, что нужны.
Тест, пересчитывающий правило, согласен с любым правилом, включая неверное
Общая форма этого стара и есть в любой книге про тестирование. Записано здесь не изречение — записано то, что этот репозиторий письменно выспорил себе исключение, в том же релизе, где чинил тот же класс багов, и заплатил за это дважды за сутки.
tests/test-memory-link.sh требовалось знать, куда Claude Code кладёт каталог памяти проекта, и он вычислял ответ так же, как его вычисляет скрипт:
enc="$(printf '%s' "$repo2" | tr '/.' '--')"
2026-09-05, первый укус. В 0.16.1 починили link для пути чекаута, содержащего _, который харнесс сворачивает так же, как / и ., а скрипт — нет. Красным ничего не было. Скрипт создавал вычисленный им каталог и сообщал об успехе, --check соглашался, потому что спрашивал ту же строку, а status докладывал, что проводка на месте, — тогда как два из трёх потребителей на одной машине писали память в каталог, который харнесс никогда не открывает, причём один из них на протяжении пятнадцати сессий. Тест соглашался всё это время, потому что выводил своё ожидание, выполняя преобразование самого предмета.
Регрессионный тест для того релиза утверждал результат — жёстко заданное имя каталога, — и его комментарий говорил об этом прямо, попутно оправдывая строку выше:
строка 30 выше делает ровно это, намеренно — ей нужно лишь соглашаться со скриптом, а не судить его
2026-09-05, второй укус. Это рассуждение прожило девять часов. macOS-джоб CI начал падать и проходить на одном и том же коммите — дважды, на двух разных SHA, с интервалом в один джоб: его TMPDIR — это /var/folders/<a>/<b>/T/, и <b> не алфавитно-цифровой. Оба упавших прогона печатали /var/folders/df/djsxfhc17x95674wsm_g8s980000gn/T/tmp.…, и на этих прогонах заранее созданный тестом каталог оказывался там, куда --check уже не смотрел. Linux этого не показывал никогда — TMPDIR там /tmp. Красная main и джоб, который читается как флакующий, будучи совершенно детерминированным на входе, на который никто не смотрел.
То, что на проходивших прогонах <b> доставался без _, — объяснение, которое подходит, а не то, что этот репозиторий измерил: путь печатает только упавший прогон, поэтому пути шести прошедших не записаны нигде. Измеренное утверждение и его границы — в knowledge/notes/shell/macos-tmpdir-can-contain-underscore.md.
Почему оправдание было неверным. «Ей нужно лишь соглашаться со скриптом» — правда и бесполезность: согласие есть ровно то, что производит и неверное правило. Проверка, выведенная из своего предмета, не имеет о предмете мнения. Она сообщает, самосогласован ли код, а он самосогласован всегда, и в момент, когда правило сдвигается, проверка сдвигается вместе с ним — молча, в том же коммите, без диффа, который можно отревьюить, потому что две копии правила лежат в разных файлах и правили только одну.
Правило: построй состояние, а не адресуй его. Починка прописывает проводку репозитория, спрашивает файловую систему, какой каталог скрипт создал на самом деле, и подменяет его каталогом. Правила кодирования тест больше не содержит ни в каком виде, поэтому не может хранить его устаревшую копию.
Отсюда три следствия:
- тест не вправе выводить заново то значение, которое проверяет. Утверждай литерал или вычитывай артефакт обратно из мира. Если ожидаемое значение дорого записать, эта цена и есть работа теста;
- «ей нужно лишь соглашаться» — это звук, который издаёт ловушка. Та же фраза подходит к любой тавтологической проверке из когда-либо написанных, чем и убеждает. Считай письменное оправдание пересчёта отчётом о дефекте, а не комментарием;
- фальсифицируй замену. Обе починки 2026-09-05 проверили, вернув дефект на место и убедившись, что тест краснеет. Заменить проверку, соглашавшуюся с чем угодно, на проверку, проходящую по другой неверной причине, — это тот же баг с новым написанием, и в зелёном прогоне их ничто не различает.
Это та же слепота, что и form-checks-cannot-see-false в базе знаний, уровнем ниже: там линтер видит форму заметки, а не её истинность, здесь тест видит самосогласованность кода, а не его правильность. Оба зелены по построению.