skillmem — это локальная память для кодовых агентов: MCP-сервер поверх SQLite с девятью инструментами mem_*. Одну базу делят Claude Code, Codex, Cursor и другие клиенты. На Хабре уже были две статьи о нём: как чужой README становился правилом пользователя и сорок раундов ревью. Эта статья — третья и, надеюсь, последняя про ревью.
Почему «ревьюить, пока не станет чисто» не работает
После 0.11 у меня остался неприятный вывод: ревью модели без критерия — это вкусовщина. Одна модель придирается к именам, другая к гонкам, и каждая по-своему права. Стоп-условие «два чистых раунда подряд» сработало, но на вопрос «что именно гарантирует программа» оно не отвечало.
Поэтому в 0.12 сначала появился документ, а код пришёл потом.
Шаг 1. Записать обещания
docs/INVARIANTS.md — шестнадцать инвариантов, INV-01…INV-16. По каждому указано: точная формулировка, функция, которая его держит, и статус на момент релиза. Инварианты разбиты на четыре класса:
данные. Ни запись, ни поле, ни строка истории, ни файл тела, ни бэкап не теряются, не перезаписываются и не «молча не пишутся». Export →
import-vault→ export даёт тот же файл байт в байт.доверие. Одобряет и запечатывает запись только владелец за терминалом. Запечатанную запись меняет только владелец. Неодобренный текст попадает в модель только внутри рамки «это данные, не инструкции».
конкурентность. Любое решение вида «прочитал → записал» принимается внутри транзакции этой записи или через compare-and-swap.
прочее. Хуки не роняют сессию, имена сравниваются одинаково на всех ФС и так далее.
Планка релиза записана там же: ноль P1/P2 в классах «данные», «доверие» и «конкурентность». Всё остальное может уйти в Known issues, но уходит туда открыто.
Главный эффект оказался не техническим. Ревьюер перестал оценивать, хорош ли код. Он берёт конкретное обещание и ищет контрпример.
Шаг 2. Две модели и одно правило
Ревьюеров было два: Codex от OpenAI (у нас он называется Астра) и Claude Opus 5.5 от Anthropic. Модели из разных лабораторий ошибаются по-разному, ради этого их и две. Работали примерно десять дней: 0.11.0 вышел 17 сентября, 0.12.0 — 27-го.
Правило одно: находка без воспроизведения не засчитывается. Нужна команда или тест, рассуждение не принимается. Каждый фикс приходит с тестом, который падает на родительском коммите фикса. Это проверяет scripts/release-gate.sh по каждой новой тест-функции: тест, зелёный и до фикса, и после, ничего не доказывает, и гейт его не пропускает.
Property-тесты написаны на hypothesis и используют настоящих конкурентных писателей: отдельные процессы и потоки над одним файлом, без моков блокировок. Хуки фаззятся битым вводом, битой базой и плохими путями.
Что сломали
Пять находок, которые мне кажутся самыми показательными. У каждой в репозитории есть регрессионный тест.
1. Windows считает NUL терминалом. Вся модель доверия держится на одной проверке: команды владельца требуют настоящего TTY. На Windows CLI принимал stdin, перенаправленный из NUL, за консоль. Агент выполнял skillmem write … < NUL, и его запись сохранялась одобренной и запечатанной — то есть становилась правилом «от владельца». Чтением кода это не нашёл ни один ревьюер. Баг проявился, только когда CI впервые пошёл на Windows.
2. Метка времени из будущего. Хук рекапа сессии ограничен по частоте штампом-файлом. На Windows у только что записанного файла mtime может оказаться на несколько миллисекунд впереди time.time(). Возраст получался отрицательным, дебаунс решал, что лимит не действует, и пропускал второй рекап — а с ним второй вызов модели. Была и соседняя гонка, уже на всех ОС: штамп читался до захвата посессионного лока и после захвата не перечитывался, так что два Stop-хука могли пройти проверку одновременно.
3. Похожие символы закрывали рамку. Неодобренный текст заворачивается в рамку «данные, не инструкции». Тот, кто может записать запись, хочет закрыть рамку раньше времени. Литеральный закрывающий маркер мы экранировали давно, и ревьюеры нашли обходы: маркер после Unicode-разделителя строк вместо \n, после NBSP, после невидимых символов, а также маркер из похожих скобок ⨠ и ⪥, которые модель читает как < и >. Теперь всё это экранируется, а property-тест генерирует новые варианты.
4. Ctrl-C, после которого пропадали записи. Если прервать запись, пока она ждёт чужой лок, транзакция SQLite оставалась открытой. Все следующие записи через это соединение библиотека подтверждала, а при закрытии соединения они исчезали. Для REPL и библиотечных вызовов это тихая потеря данных. Теперь прерванные транзакции, вложенные savepoint’ы и упавший COMMIT откатываются до того, как прерывание уходит выше.
5. cp базы. Если скопировать файл базы и сделать export из копии, копия считала себя оригиналом: занимала его каталог бэкапов, чистила его дампы, а GC удалял файлы тел, которые оригинал ещё отдавал. Теперь копия — это отдельная база: при первом открытии она копирует себе тела, а у каталога экспорта ровно один владелец.
Из того же класса. На регистронезависимых ФС (APFS, NTFS) «двойник» по регистру заставлял прунинг удалить новый дамп, а вложения Σ.png и ς.png путались. upgrade отправлял сохранённый GitHub-токен через редирект на другой хост. На Python до 3.14 Path.is_dir() бросал PermissionError вместо False.
Во что это обошлось
Время: около десяти дней в цикле ревью → фикс → ревью.
Тесты: было около 430, стало около 2 538, в основном property и регрессионные.
CI: полный прогон на Linux, macOS и Windows × Python 3.11–3.13. Плюс отдельный job семантического поиска, сборка и Docker-проверка, что образ стартует и отдаёт 9 инструментов. Релиз на PyPI прогоняет всё это заново на теге.
Неудобство для пользователей. 0.12 отказывает там, где 0.11 молча соглашался: команды владельца без TTY, запись поверх архивной записи, импорт значений, которые раньше приводились к типу. Поэтому release notes открываются разделом «Before you upgrade». Релиз корректности ломает сценарии, которые работали случайно.
Деньги: счёта за токены нет. Обе модели работали по фиксированным подпискам.
Что сознательно не починили
Записать инварианты — значит записать и то, где ты до них не дотягиваешь. В CHANGELOG есть раздел Known issues, примерно двадцать пунктов, и у каждого указан инвариант, который он нарушает. Самые важные:
Deny-правила и проверка TTY не защищают от агента с шеллом.
script -qec "skillmem tr''ust x" /dev/nullпроходит обе проверки. Настоящая изоляция — это песочница, а не сравнение строк.Слаги, kind, project и теги показываются модели без рамки (INV-07).
Журнал «уже показано» у хуков не залочен, поэтому параллельные PreToolUse-хуки могут подмешать одно правило дважды.
На Windows катастрофически бэктрекающий
SKILLMEM_VERIFY_PATTERNне останавливается собственным сторожем skillmem. Его обрывает только таймаут хука Claude Code, 10 секунд.uninstall --purge-dbотказывает, пока MCP-сервер держит базу открытой.
Ни один из этих пунктов не относится к P1/P2 в «данных», «доверии» или «конкурентности», иначе релиза бы не было. Но они настоящие, и лучше вы прочтёте о них здесь, чем наткнётесь сами.
Вывод
Просьба к модели «отревьюй код» даёт стилистику и немного багов. Просьба «нарушь вот это записанное обещание, доказательство — только падающий тест» даёт баг с NUL на Windows. Файл инвариантов оказался ценнее любого отдельного фикса: следующий ревьюер, человек или модель, начинает с него.
pip install -U skillmem skillmem init --claude-code # перезапустить: новые deny-правила

