Применение и откат
Механический исполнитель с четырьмя правилами безопасности, резервная копия, способная доказать, что она восстанавливает, и парсер, отвергающий всё неоднозначное.
handbook apply --source <repo> --plan plan.md --dry-run # verify only
handbook apply --source <repo> --plan plan.md # for real
handbook rollback --backup <dir> # undoLLM не участвует. apply подставляет точный текст вместо точного текста. Всё
интересное в нём — то, что он отказывается делать.
Сначала всегда пробный прогон
handbook apply --source $REPO --plan plan.md --dry-run{
"ok": true,
"dryRun": true,
"outcomes": [
{ "index": 1, "file": "src/upload.py", "where": "Uploader.send (~88)", "status": "applied", "line": 88 },
{ "index": 2, "file": "src/upload.py", "where": "Uploader", "status": "applied", "line": 71 }
],
"changedFiles": [],
"problems": []
}ok: true означает, что каждый якорь разрешился. changedFiles пуст, потому что ничего
не записывалось. --dry-run никогда не трогает файловую систему.
Четыре правила безопасности
1. Сначала проверить всё, затем записать в две фазы
План сначала разрешается относительно текущего содержимого файлов. Один сбой прерывает всё применение — до записи хоть одного байта. Затем запись складывает каждый файл во временный файл и выполняет переименования только после того, как вся подготовка удалась, — а если переименование срывается на середине, уже переименованные файлы восстанавливаются из резервной копии, снятой мгновением раньше.
Не существует состояния, в котором приземлилась половина плана.
2. old должен совпадать байт-точно и единственным образом
| Совпадений | Результат |
|---|---|
| 0 | no-match — код ушёл вперёд с момента написания плана |
| 1 | применено |
| 2+ | ambiguous — якорь не указывает на единственное место |
Оба сбоя — отказ. Ни один не выбирает за вас. «Взять первое вхождение» — ровно тот способ, которым патч приземляется не в ту функцию.
3. Каждый затронутый файл сохраняется в резервную копию с хешем до патча
<source>/.handbook-patches/
.gitignore written automatically — backups never enter git
2026-08-08T14-05-11-204Z/
manifest.json source root, timestamp, per-file pre/post hashes
files/… the original bytesИменно хеш позволяет откату доказать, что он восстанавливает байты, которые заменил этот патч, а не доверять имени файла.
4. Ни один путь не выходит за корень исходников
.., абсолютные пути, абсолютные пути Windows с буквой диска — и побеги через
симлинк на родительский каталог, когда сам файл ещё не существует. Последний случай —
самый тонкий: realpath берётся от самого глубокого существующего предка, поэтому
отсутствующий лист не может проскочить проверку. Цели-симлинки никогда не заменяются.
Статусы исходов
| Статус | Значение |
|---|---|
applied | Заменено, с номером строки (нумерация с 1), где найден old |
created | old был пуст; файл создан |
no-match | old в файле отсутствует |
ambiguous | old встречается более одного раза |
file-missing | Непустой old, но такого файла нет |
not-a-file | Путь — каталог или симлинк |
unsafe-path | Путь выходит за корень исходников |
undecodable | Файл — не валидный UTF-8 |
skipped | Более ранний сбой прервал запуск |
apply выходит с кодом 2, когда ok равен false.
Откат
handbook rollback --backup $REPO/.handbook-patches/2026-08-08T14-05-11-204Z \
--source $REPO- Отказывается от любого файла, изменённого после патча. Его текущий хеш больше не
совпадает с пост-патчевым хешем в манифесте — значит, кто-то с тех пор его редактировал,
и восстановление молча уничтожило бы эту работу.
--forceпереопределяет — намеренно явно. --sourceстрахует в другую сторону: направить откат на резервную копию, снятую с другого дерева, — это ошибка, а не возможность.- Права файлов, окончания строк и завершающий перевод строки сохраняются на всём пути. Патчер не нормализует ничего, что его не просили менять.
- Пустые каталоги, созданные самим откатом, вычищаются.
ls -1t $REPO/.handbook-patches/ # newest firstПочему парсер враждебен к неоднозначности
Отслеживание ограждений следует CommonMark и для backtick-, и для tilde-ограждений: блок,
открытый серией из N маркеров, закрывается только строкой, чья серия ≥ N и не несёт
info string. Поэтому ### EDIT n внутри огороженной области — содержимое, а не
заголовок: план, цитирующий пример правки, не может протащить в запуск фантомную
правку.
| Отклоняется | Сообщение подскажет |
|---|---|
| Содержимое между огороженными блоками правки | Внутреннее ограждение, вероятно, закрыло old/new раньше времени — откройте их более длинным ограждением |
| Блок ``` без метки | Та же причина; отклоняется, где бы он ни стоял, чтобы обрезанный якорь не проскочил как «эпилог» |
Не ровно один old и один new | Сколько каждого он нашёл |
new раньше old | Сначала пишется якорь, затем замена |
old идентичен new | Нечего делать |
Отсутствующая или продублированная строка - file: | Требуется ровно одна |
| Номера правок не по порядку или продублированы | Они должны возрастать |
Путь с пробелами, обратными кавычками, управляющими символами, обратными слэшами, ~ или ведущим / | Какое именно правило нарушено |
Заголовок-почти (## EDIT 1) | Он похож на заголовок, но это не ### EDIT <n> |
Завершающая проза и блок деклараций после последней пары old/new — ожидаемый
вывод; они игнорируются, а не отклоняются.
Написание плана вручную
Ничто не требует, чтобы план приходил из handbook plan. Формат достаточно мал, чтобы
писать его напрямую, — а это делает apply полезным механическим патчером и сам по себе:
### EDIT 1
- file: `src/config.py`
- where: `DEFAULTS` — bump the timeout
```old
TIMEOUT_SECONDS = 30
```
```new
TIMEOUT_SECONDS = 60
```Проверьте план без применения:
import { parsePlan } from '@handbooks/patcher';
console.log(parsePlan(planText).problems);После приземления
Handbooks теперь отстаёт от кода. Прокатите его вперёд:
handbook resync --case cases/upload-retry --work work/apiПланирование изменения
Дайте планировщику запрос и handbook — получите байт-точный план правок и машиночитаемую декларацию того, чего он касается.
Поддержание актуальности
Resync сравнивает старый граф вызовов с новым и перегенерирует только то, что действительно изменилось. Тронули три файла — платите за три файла.