# Octofs 0.9.0: строка 42 — это ложь

> Octofs 0.9.0 даёт каждой строке составной идентификатор с проверкой по содержимому (N:hh): инструменты редактирования сверяют его с файлом перед записью, поэтому устаревшая цель падает с ошибкой и актуальным содержимым вместо того, чтобы молча отредактировать не ту строку. Плюс replace_all и корректная работа с CRLF в str_replace и жёсткий отказ при злоупотреблении shell. Открытый исходный код, Apache-2.0.

# Octofs 0.9.0: строка 42 — это ложь

Агент попросил заменить строки с 40 по 44. Он получил строки с 40 по 44. Это были не те строки, которые он читал.

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

Ради этого сценария отказа релиз и существует.

**0.9.0 делает адрес каждой строки проверяемым по содержимому: строка — это `N:hh`, её позиция плюс хеш того, что на ней написано, и каждый инструмент редактирования сверяет хеш с файлом, прежде чем записать хоть байт.** Устаревшая цель теперь падает с явной ошибкой, в которой лежит актуальное содержимое. Попасть не в ту строку невозможно, потому что «не та строка» больше не совпадает.

Вместе с этим вышли ещё два изменения — и, как выясняется, это одна и та же идея в разных костюмах. Об этом в конце.

---

## Номер строки — неправильный примитив

Вот в чём беда номера строки: он верен только в тот момент, когда вы его прочитали.

Цикл правок агента — это последовательность отдельных вызовов MCP с паузами между ними, и в этих паузах файл не заморожен. Его правит другой вызов инструмента. Форматтер срабатывает при сохранении. Параллельный агент трогает тот же файл. Человек, наблюдающий за сессией, исправляет опечатку. К моменту, когда правка доезжает, «строка 42» указывает на то, что случайно оказалось в 42-м слоте, — и файловый сервер, принимающий голое целое число, никак не отличит строку, которую имела в виду модель, от строки, которую он сейчас уничтожит.

Обычное решение — глобальная проверка устаревания файла: поставить отметку при чтении и отклонить правку, если mtime или хеш изменились. У нас была такая. Она груба в обе стороны. Она отклоняет правку строки 900, потому что кто-то тронул строку 3, и чтобы восстановиться, требует перечитать файл целиком — дорого, причём перечитанное тут же снова устаревает. Хуже того, она не говорит ничего полезного. «Файл изменился» оставляет модели ровно один ход: прочитать весь файл заново и надеяться, что в этот раз она выиграет гонку.

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

---

## `N:hh` — позиция плюс доказательство

В 0.9.0 `view` выводит каждую строку как `N:hh|content`:

```
1:a3|fn main() {
2:f1|    println!("Hello");
3:0e|}
```

`N` — позиция с нумерацией от единицы. `hh` — два шестнадцатеричных символа, хеш FNV-1a от содержимого строки, свёрнутый с 32 бит до 8. Инструменты редактирования принимают эти составные идентификаторы обратно как цели, а `verify_line_id` сверяет хеш с файлом в момент применения. Совпало — правка идёт. Не совпало — не записывается ничего.

Хеш считается **только по содержимому**, никогда по позиции. Когда мы это писали, это выглядело деталью, а оказалось сутью всей конструкции. Раз строка сохраняет свой хеш при переезде, неудачная проверка может пойти и поискать, куда делось содержимое: просканировать файл на строки с ожидаемым хешем — и вы знаете, что цель не исчезла, а сдвинулась на три строки вниз.

Ровно это ошибка и сообщает:

```
Stale line id "42:c7" — the file changed since you viewed it. Current content around line 42:
40:1b|    let config = load_config()?;
41:9f|    let client = Client::new(&config);
42:2e|    tracing::info!("client ready");
43:0a|
44:5d|    run(client).await
Content matching hash c7 is now at: 45:c7 (your target may have moved).
Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.
```

В этом сообщении три вещи, и каждая осознанная. Актуальное содержимое вокруг цели со свежими идентификаторами — чтобы модель могла сразу перенацелиться. Где сейчас живёт содержимое с ожидаемым хешем, ближайшие кандидаты первыми — чтобы переехавшая, но не изменившаяся строка чинилась одним шагом. И конкретный диапазон для `view`, если модель хочет убедиться, а не гадать.

Модель восстанавливается по одной только ошибке. Никакого перечитывания файла на 2000 строк, никакой второй гонки, никакого сожжённого контекста. **Ошибка и есть инструкция по восстановлению.**

А поскольку результаты правок возвращаются в виде дифов со свежевычисленными идентификаторами, правки выстраиваются в цепочку. Сделайте три вызова `batch_edit` подряд — и цели второго берутся из ответа первого: файл между ними не нужно перечитывать вообще.

Есть один честный компромисс, и он записан комментарием в исходниках, а не спрятан: восемь бит означают, что изменившаяся строка сохранит свой хеш с вероятностью 1/256. Мы на это пошли. Идентификаторы остаются достаточно короткими, чтобы быть дешёвыми в контексте и читаемыми в транскрипте, проверка позиции ловит любой заметный сдвиг, а альтернатива — более длинные хеши на каждой строке каждого просмотра файла — стоила бы токенов буквально при каждом чтении ради защиты от случая с вероятностью 0,4%, который цикл «диф со свежими идентификаторами» и так обычно вскрывает.

Ещё мы убрали переключатель режимов. В предыдущих версиях был флаг `--line-mode`, выбиравший между адресацией по номерам и по хешам. **Этого флага больше нет, формат `N:hh` обязателен** — это ломающее изменение в 0.9.0. Два режима адресации означали, что каждое описание инструмента должно объяснять оба, каждая модель должна догадаться, с каким из них она разговаривает, а безопасный режим включался вручную. Безопасность, спрятанная за флагом, — это безопасность, которую большинство никогда не включит.

Голые целые числа по-прежнему работают там, где одной позиции действительно достаточно и проверять нечего по определению: диапазоны в `view` (отрицательные значения отсчитываются с конца) и якоря вставки `0` — начало файла и `-1` — в конец. Всё, что нацелено на _существующее_ содержимое, требует идентификатор.

---

## Правка, которая прошла успешно и ничего не сделала

Пока мы были внутри, нашлась более тихая версия того же бага.

`str_replace` подбирает совпадение поэтапно: сначала точное, затем нечёткое с нормализацией пробелов — на случай, когда у модели поехали отступы. В файле с переводами строк CRLF нечёткий этап находил совпадение на нормализованном тексте, а затем пытался вставить замену обратно в сырое содержимое, где каждая строка по-прежнему заканчивалась на `\r\n`, — и вставлять было некуда. Файл записывался байт в байт таким же, а в ответ приходил успех с дифом. Правка разработчика на Windows проходила весь путь и не меняла ничего.

Теперь всё сопоставление идёт в пространстве LF, а `restore_endings` возвращает `\r\n` при записи. То же самое в `batch_edit`. Файл сохраняет свои переводы строк; сопоставлению до них больше нет дела.

Полная лестница совпадений в 0.9.0 выглядит так: **точное** → **восстановление экранированных литералов** → **нечёткое с нормализацией пробелов и подгонкой отступов** → **диагностика**. Второй этап новый и целиком построен вокруг того, как ошибаются модели: когда модель дважды экранирует свой JSON и присылает литеральные обратный слеш и n вместо перевода строки, мы интерпретируем экранирование, и если _так_ совпадение оказывается единственным — применяем его и добавляем подсказку о том, что сделали. Модели ошибаются так постоянно, случай однозначный, а возврат ошибки на него стоил лишнего круга ради исправления того, чего нет.

`replace_all` тоже новый — та самая правка в стиле переименования, которая раньше требовала либо достаточного окружающего контекста для уникальности каждого вхождения, либо `batch_edit` с отдельной операцией на каждое место. А когда точное совпадение срабатывает несколько раз без `replace_all`, ошибка теперь перечисляет каждое место _в виде идентификатора строки_:

```
Found 3 matches for replacement text at:
  1. 12:a3
  2. 88:a3
  3. 140:a3
Add more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.
```

Три названных выхода, и каждый из них исполним без ещё одного `view`. Снова тот же приём.

---

## Подсказка — это совет, а советам модели следуют выборочно

Третье изменение кого-то разозлит, так что позвольте его обосновать.

Octofs распознаёт злоупотребление shell — когда модель тянется за `cat`, `grep`, `find`, `ls`, `sed` или `awk` там, где специализированный MCP-инструмент справится лучше. До 0.9.0 это поведение настраивалось флагом `--hint-mode`: мягко предупредить или отклонить. С 0.8.1 мягкий режим был по умолчанию.

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

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

```
Searching file text with this command is forbidden — use `view` with content= instead
(gitignore-aware, context lines, line numbers, works on remote hosts).

  Example:
    view path="src/main.rs" content="fulfill_input_requests"
    view path="src/" content="TODO" regex=true
    view path="ssh://user@host/dir" content="TODO"  # remote search — no `ssh grep` needed
```

Дело не в аккуратности. `view` с `content=` возвращает идентификаторы строк, которые принимают инструменты редактирования, уважает `.gitignore` и прозрачно работает с путями `ssh://`. Сырой вывод `grep` даёт модели номер строки — то есть, по всему сказанному выше, ложь, которая ждёт своего часа, — и тихо топит его в `node_modules`. Специализированный инструмент строго полезнее, так что вопрос был только один: делать ли этот выбор необязательным. Больше нет.

Что по-прежнему работает: **конвейеры**. `cargo build 2>&1 | grep error` — это преобразование потока, а не чтение файла, и детектор намеренно не разбивает команду по `|`. Он разбивает по `;`, `&&`, `||`, переводам строк, `$(` и обратным кавычкам — и только вне кавычек, так что `ssh host 'cd /path && ls'` не даёт ложного срабатывания на удалённой команде, в которую детектору лезть незачем. Префиксы переменных окружения пропускаются, чтобы добраться до настоящей программы, а `/bin/grep` сводится к `grep`, чтобы путь к бинарнику не стал лазейкой.

---

## Что общего у этих трёх изменений

Посмотрите на них вместе — и это одно изменение, сделанное трижды.

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

Поэтому: не говорите «файл изменился» — скажите, на что он изменился и куда уехало содержимое. Не говорите «найдено 3 совпадения» — перечислите их как цели, которые примет следующий вызов. Не намекайте, что `grep` не приветствуется, — падайте и отдавайте точный вызов `view`, который его заменяет. Каждый из этих шагов — один и тот же приём: потратить пару сотен символов в ошибке, чтобы сэкономить целый круг общения и сожжённый на него контекст.

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

---

## Обновление

```bash
# Homebrew
brew upgrade muvon/tap/octofs

# Cargo
cargo install octofs --version 0.9.0
```

Готовые сборки для Linux, macOS и Windows (x86_64 и ARM64) лежат на [странице релизов](https://github.com/muvon/octofs/releases), а конвейер релиза теперь публикует пакет в npm вместе с crates.io и реестром MCP.

**Одно ломающее изменение, о котором стоит знать:** если в конфигурации вашего MCP-клиента заданы `--line-mode` или `--hint-mode`, уберите их — обоих флагов больше нет, и бинарник их отвергнет. Заменять их нечем: безопасное поведение теперь единственное. Других изменений в конфигурации не требуется.

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

---

Octofs — открытый исходный код (Apache 2.0), [github.com/Muvon/octofs](https://github.com/Muvon/octofs). Если вам нужен взгляд с точки зрения безопасности — почему агенту вообще стоит давать ограниченный файловый инструмент вместо голого shell, — это [отдельная статья](/blog/give-an-ai-agent-a-filesystem-safely); эта была о том, чтобы инструмент правил ровно ту строку, на которую его навели.
