Бесплатный интерактивный курс · aicoding.space

Движок под dz: что происходит, когда ты нажимаешь Enter

Курс про шесть пакетов, которые никто не ставит руками, но которые работают каждый раз, когда ты набираешь dz. Вместе с Тимуром ты прочитаешь граф зависимостей прямо из package.json, разберёшь, где у движка чистое ядро и где грязная оболочка, спустишься от строчки в терминале до модуля, который её исполняет, поймёшь, почему атомарной записи мало и нужен замок, и научишься сам находить любую точку входа в движок.

Содержание курса

1. Что лежит под dz: шесть пакетов движка

Один инструмент в терминале — и шесть библиотек под ним, которые ты никогда не ставил руками.

Ключевая мысль: движок под dz собран из шести отдельных пакетов

Тимур год набирал dz teach и dz init и считал, что dz — это одна программа. Сегодня ему прилетела заявка: «урок пропал, а команда отчиталась успехом». И выяснилось, что чинить придётся не dz.

dz — это дверь, а не дом. За дверью стоят шесть отдельных пакетов, каждый со своей версией, своим README и своей страницей на npm. Ты их никогда не устанавливал напрямую — они приезжают как зависимости, — но именно они решают, что произойдёт после Enter.

Вот кто там живёт (версии измерены npm view <пакет> version 2026-09-02):

  • @dzhechkov/core 0.2.22 — контракты: схема навыка, схема хуков, договор для адаптеров платформ.
  • @dzhechkov/memory 0.2.19 — хранилище выученного: бэкенды, поиск, обратная связь.
  • @dzhechkov/harness-core 0.8.11 — общий мозг: почти вся логика команд dz живёт здесь.
  • @dzhechkov/harness-presets 0.5.18 — именованные наборы навыков для dz init.
  • @dzhechkov/mcp-server-tools 0.2.10 — мост наружу: те же операции по протоколу MCP.
  • @dzhechkov/keysarium-core 1.1.27 — второе ядро, для многоагентных конвейеров.

Исходники всех шести — в публичном репозитории github.com/djd1m/dz-harness, каталог packages/@dzhechkov/.

Зачем вообще было делить? Затем, что версии двигаются с разной скоростью. harness-core за это время дошёл до 0.8.11, а core — только до 0.2.22: контракты меняются редко, логика — постоянно. Слепи их в один пакет — и каждая правка в логике заставляла бы всех потребителей контрактов обновляться.

Компромисс. Шесть пакетов — это шесть версий, шесть чейнджлогов и настоящие правила совместимости вместо «поправил и поехали». Взамен ты получаешь возможность взять из движка ровно одну часть: например, подключить @dzhechkov/memory в свой сервис, не таща за собой ни одной команды dz.

Дальше по курсу Тимур не будет читать эти шесть README подряд. Он пойдёт другим путём: сначала измерит, кто от кого зависит, потом спустится по одной-единственной команде вниз — и по дороге найдёт свою пропавшую запись.

2. Кто от кого зависит: граф, прочитанный по package.json

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

Ключевая мысль: направление зависимости читается по package.json

Первое, что сделал Тимур, — угадал. «Ну, core же ядро, значит все зависят от него, а keysarium-core наверху, он же оркестратор». Половину он угадал неверно.

Направление зависимости не выводится из названия — оно читается из package.json. Один воспроизводимый вызов, и спорить больше не о чем:

node -e "console.log(Object.keys(require('./packages/@dzhechkov/harness-core/package.json').dependencies))"

Вот что вернулось для всех шести (измерено 2026-09-02):

  1. core → зависит только от zod. Ни одного пакета движка. Это основание.
  2. memory → ноль зависимостей вообще; better-sqlite3 объявлен как необязательная (optionalDependencies). Тоже основание.
  3. harness-core → зависит от core, от memory и от десяти адаптеров платформ.
  4. mcp-server-tools → зависит от core, harness-core, harness-presets и тех же адаптеров.
  5. harness-presets → ноль зависимостей. Это данные, а не логика.
  6. keysarium-core → ноль зависимостей. Ни на один из остальных пяти.

Читается это так: стрелки идут снизу вверх и никогда не заворачивают обратно. core и memory не знают, что над ними кто-то есть, — и именно поэтому их можно тестировать и переиспользовать отдельно. Циклическая зависимость («A знает про B, а B знает про A») сломала бы это: ни один из двух больше нельзя было бы собрать или проверить в одиночку.

Первое опровержение по курсу. Догадка Тимура про keysarium-core как оркестратор над движком не подтвердилась: в его package.json поле dependencies пустое. Это не ошибка и не недоделка — почему так, разберём в секции 13. Пока запомни сам приём: сначала измерь, потом рассказывай.

Компромисс. package.json даёт неоспоримое, но грубое: он показывает объявленные пакетные зависимости и молчит про фактические импорты внутри одного пакета и про необязательные связи. Зато он не врёт и не устаревает вместе с README.

3. Чистое ядро и грязная оболочка

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

Ключевая мысль: чистое ядро не трогает диск

Тимур ожидал увидеть внутри harness-core сплошную работу с файлами. Измерение дало обратное.

cd packages/@dzhechkov/harness-core/src && ls *.ts | wc -l && \
  grep -l "node:fs\|node:child_process\|node:os" *.ts | wc -l

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

Это не случайность, а сознательное деление на чистое ядро и грязную оболочку:

  • Чистое ядро — функция, которая берёт данные на входе и возвращает данные на выходе. Ничего не читает, ничего не пишет, не смотрит на часы и не ходит в сеть. contract-checklist.ts про себя так и заявляет: он не делает ни файлового, ни процессного, ни сетевого ввода-вывода, ни обращений к часам и локали — байты артефакта ему передают аргументом. restart-advisor.ts устроен так же: принимает байты JSONL и явный порог.
  • Грязная оболочка — тонкий слой, который единственный умеет читать файлы и писать их. В README harness-core про синхронизацию политик сказано прямо: runSyncAgentsPolicy — это оболочка ввода-вывода вокруг чистого модуля agents-policy.

Зачем так. Затем, что тест чистой функции — это вызов. Никакой временной папки, никаких прав, никакой уборки после себя, никакой зависимости от того, что было на диске у предыдущего теста. Проверка, которую можно записать как «дай вход — сравни выход», всегда дешевле и надёжнее проверки, которой нужно окружение. Отсюда и цифра: в harness-core лежит 250 тестовых файлов (ls test | wc -l) — столько тестов держать выполнимо ровно потому, что большинству из них не нужен диск.

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

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

4. Дверь: путь dz teach от терминала до модуля

Сквозной спуск: строчка в терминале, ветка в разборщике команд, функция в ядре, запись в хранилище.

Ключевая мысль: команда доходит до функции recordPattern

Вот тот самый спуск, ради которого Тимур открыл исходники. Одна команда — и пять остановок до диска.

Остановка 1. Терминал. Ты набираешь:

dz teach "pipe глотает код возврата — проверяй PIPESTATUS" --domain shell

Остановка 2. Разборщик команд. packages/@dzhechkov/harness-cli/src/cli.ts — один большой switch по имени команды. Ветка выглядит буквально так: case 'teach': return await cmdTeach(options, flags, cwd, write, writeErr, …). Заметь, чего здесь нет: ни одного решения о том, что такое урок. CLI разобрал аргументы и передал дальше.

Остановка 3. Функция команды. cmdTeach в том же файле делает ровно две вещи: решает, в какое хранилище писать (флаг --to, затем переменная окружения DZ_LEARN, затем .dz/config.json, затем проект — и неизвестное значение отвергается, а не подменяется молча значением по умолчанию), и печатает результат человеку.

Остановка 4. Ядро. cmdTeach вызывает recordPattern(storeRoot, rec) — а это уже импорт из @dzhechkov/harness-core, файл src/patterns.ts. Вот здесь заканчивается командная строка и начинается движок. Функция recordPattern решает, свежий ли это урок (карантин), выбирает бэкенд, берёт замок и пишет.

Остановка 5. Хранилище. Внутри recordPattern запись превращается в общий формат (patternToRecord) и уезжает в JsonFileBackend — а это уже пакет @dzhechkov/memory.

Вот и вся анатомия. Три пакета на одну команду: CLI разбирает, harness-core решает, memory хранит. И приём отсюда важнее самой карты: чтобы найти любую команду dz, ищи case '<имя>' в cli.ts, оттуда — имя функции cmd…, а оттуда — что она импортирует из harness-core. Три grep-а, и ты на дне.

Компромисс. Тонкая дверь означает, что понять поведение по одному файлу нельзя — придётся прыгать между пакетами, и стек вызова длиннее. Взамен ровно та же логика доступна не только из терминала: MCP-сервер из секции 12 зовёт те же функции, минуя cli.ts целиком.

5. Урок как запись: PatternRecord и MemoryRecord

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

Ключевая мысль: урок хранится как запись PatternRecord

«Урок» звучит как текст. На диске это строго типизированная запись — и Тимур увидел её, открыв patterns.ts.

PatternRecord — форма урока на языке движка. Семь полей, каждое отвечает на свой вопрос:

  • pattern — сам текст правила. То, что ты набрал в кавычках.
  • type — что это: rule, success-pattern или lesson-learned.
  • reward — сигнал полезности в отрезке от нуля до единицы.
  • domain — область (shell, api, general): по ней потом бустится выдача.
  • ts — момент записи, ISO-8601.
  • source — откуда приехало (например, dz-teach).
  • lessonForm / lessonPairId — необязательная пара «конкретный урок ↔ его обобщение».

MemoryRecord — форма записи на языке хранилища. Другие имена и другой набор: id, skillId, text, score, outcome, timestamp, metadata.

Между ними стоят две функции-переводчика, patternToRecord и recordToPattern. В коде они прямо помечены как anti-corruption mapping — «слой защиты от порчи»: приём, при котором две модели данных общаются только через явный перевод, чтобы словарь одной не протёк в другую. pattern становится text, reward становится score, а domain и source уезжают в metadata. Поле skillId остаётся пустым — и в коде рядом объяснение: выученный урок не привязан ни к одному навыку.

Зачем такая церемония. Затем, что у memory своя жизнь: там бэкенды, поиск, ранжирование, и записи туда кладёт не только dz teach. Если бы движок писал прямо в чужую форму, любое переименование поля в хранилище ломало бы команду. А так ломается ровно одна функция — и ломается заметно, на этапе компиляции.

Настоящий шрам в этом переводчике. В patternToRecord есть защитная строчка: если у старой записи поля type не было вовсе, вместо неё подставляется lesson-learned. Комментарий рядом объясняет цену пропуска: undefined лёг бы в колонку SQLite как NULL, нарушил ограничение NOT NULL — и dz teach перестал бы работать для всего проекта в момент, когда такая старая запись впервые попала бы в общую пачку. Одна строчка стоит между «работает» и «команда мертва у всех».

Компромисс. Два представления одного урока — это дублирование: добавил поле — правь в двух местах и в двух функциях. Взамен ни одно изменение в хранилище не доходит до команды молча.

6. Потерянный урок: почему атомарной записи мало

Заявка Тимура закрывается здесь: два писателя, оба вернули успех, урок один.

Ключевая мысль: атомарная запись не спасает от потерянного обновления

Возвращаемся к заявке, с которой Тимур начал: урок пропал, а команда отчиталась успехом. Ни одного сообщения об ошибке, код возврата ноль, записи нет.

Первая гипотеза звучала разумно: «наверное, файл побился при одновременной записи». Она неверна. Хранилище пишет атомарно — во временный файл, затем переименование. Ни один читатель никогда не увидит полуфайл.

Но атомарность отвечает не на тот вопрос. Вот что на самом деле происходит, когда две команды dz teach идут внахлёст:

  1. Процесс A читает хранилище: N записей.
  2. Процесс B читает то же хранилище: те же N записей.
  3. A добавляет свою и пишет N+1 обратно. Атомарно.
  4. B добавляет свою — к своей копии, где было N, — и пишет свои N+1. Тоже атомарно.
  5. Переименование B побеждает. Урока A нет. Оба процесса вернули успех.

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

Это измерено, а не рассказано. Четыре писателя, синхронизированные на общем барьере, все вышли с кодом ноль — на диске осталось одно обновление из четырёх. Те же четыре под именованным замком — все четыре на месте.

Поэтому в recordPattern ветка JSON обёрнута в withStoreLock, и рядом лежит комментарий, объясняющий именно это. Ветка SQLite туда не заходит: у неё собственные транзакционные блокировки.

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

Компромисс. Замок сериализует писателей: два dz teach одновременно уже не пройдут параллельно, и появляется новый класс отказов — «не дождался замка». Взамен исчезает худший вид ошибки — тот, где всё зелёное, а данных нет.

7. Запрос, который ничего не нашёл

Поиск, который всегда что-то возвращает, — это поиск, который ничего не сообщает.

Ключевая мысль: запрос без совпадений возвращает пустоту

Тимур записал урок и пошёл проверять, что он найдётся. Нашёлся. Но нашлось и всё остальное — и вот тут начинается самое интересное в пакете memory.

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

Измерение на двух записях (hello world и another record): запрос zebrafish вернул обе. Запрос hello, который совпадает ровно с одной, — тоже обе. При нуле совпадений сортировка вырождалась в порядок по уверенности — и снаружи это выглядело как «ранжирование по уверенности», хотя было отсутствием фильтра.

Задай себе вопрос, на который отвечал этот код. «Какие записи похожи на запрос?» — на него он отвечал честно. А пользователь задавал другой: «есть ли вообще подходящая запись?» На этот вопрос ответ «вот всё хранилище» — не ответ.

Как стало. Три случая наконец различаются:

  1. Есть пригодные слова, есть совпадения → возвращаются только совпавшие.
  2. Есть пригодные слова, совпадений нет → возвращается пустота.
  3. Пригодных слов нет вообще (одна пунктуация, один символ) → возвращается всё хранилище по уверенности — потому что фильтр здесь просто не выразим.

Фильтр записан как overlap > 0, а не как подобранный порог. Логика прямая: слабое совпадение — всё ещё совпадение, а нулевое совпадение — не слабое совпадение. Оба бэкенда получили одно правило, так что ответы хранилища не зависят от того, какой из них у тебя установлен.

Второй шрам, кириллический. Разбиение текста на слова шло по классу [^a-z0-9]+. Любая нелатинская буква была разделителем — значит, русский запрос давал ноль слов, ветка полнотекстового поиска пропускалась, релевантность становилась одинаковой для всех записей, и порядок решал тай-брейк по уверенности. Измерено на хранилище из 267 записей: русский топ-1 — 0 из 10, английский — 10 из 10, при том что 63% реального трафика поиска кириллические. Класс заменён на [^\p{L}\p{N}]+ с флагом Unicode. Миграция не понадобилась: индекс всегда был правильным — слова терял именно запрос по дороге наружу.

Компромисс. Пустой ответ честнее, но он холоднее: раньше пользователь всегда что-то видел, теперь может увидеть ничего и решить, что хранилище сломано. Цена честности — необходимость объяснять пустоту в интерфейсе.

8. Каскад бэкендов: почему по умолчанию простой JSON

Тяжёлое хранилище — не зависимость, а возможность. Разбираем, как движок падает вниз мягко.

Ключевая мысль: каскад пробует бэкенды и откатывается к гарантированному

Тимур ожидал увидеть в основании базу данных. В основании оказался обычный JSON-файл — и это решение, а не бедность.

Бэкенд по умолчанию — JsonFileBackend: чистый JavaScript, ноль зависимостей. Ни нативной сборки, ни WebAssembly, ни скачивания модели. Он работает везде, где работает Node, и целиком проверяется тестами.

Каскад — это функция selectBackend: она по очереди пробует необязательные бэкенды и, если ни один не доступен, откатывается к гарантированному. Тяжёлые варианты (agentdb, SQLite) сознательно не объявлены жёсткими зависимостями — в package.json пакета memory поле dependencies пустое, а better-sqlite3 лежит в optionalDependencies.

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

Разница между «пробуем» и «требуем» — это и есть каскад. Требование — это условие входа: не выполнено, значит вход закрыт. Проба — это вопрос: получилось, работаем быстрее; не получилось, работаем медленнее.

И обрати внимание на связку с секцией 6: у SQLite собственные транзакционные блокировки, поэтому ветка SQLite не заходит в замок хранилища вовсе. Смена бэкенда меняет и то, кто отвечает за одновременный доступ. Это не деталь реализации — это смена договора.

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

9. Контракт: схема навыка в двух слоях

Как один формат навыка одновременно соблюдает открытый стандарт и вмещает два десятка своих полей.

Ключевая мысль: двухслойная схема frontmatter

Спускаемся на самое дно — в @dzhechkov/core. Тимур ожидал увидеть код; увидел договор.

Задача, которую там решают, звучит противоречиво. Открытый стандарт Agent Skills описывает шапку файла SKILL.md — так называемый frontmatter, блок метаданных в начале файла — и разрешает ровно шесть полей: name и description обязательные, license, compatibility, metadata, allowed-tools необязательные. При этом Claude Code добавляет свои поля, а навыки этого проекта носят ещё около двадцати шести собственных ключей. Соблюсти стандарт и вместить своё одновременно — как?

Ответ: два слоя вместо одного компромисса.

  • CanonicalSkillFrontmatterстрогий стандарт agentskills.io, буква в букву. По нему живёт канонический слой и все адаптеры не-Claude платформ.
  • ClaudeSkillFrontmatter — та же схема, но ослабленная, плюс расширения Claude Code, плюс сквозной пропуск неизвестных ключей. Поэтому каждый существующий SKILL.md проходит проверку без единой правки.

Обе схемы описаны на zod — библиотеке проверки данных во время выполнения, единственной зависимости пакета core (dependencies содержит ровно zod, проверь сам).

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

Ещё один договор из того же пакета. Adapter — контракт, который реализует каждый @dzhechkov/adapter-*. Именно поэтому harness-core умеет раскладывать навыки на десять платформ, ничего не зная про каждую из них по отдельности: он разговаривает с контрактом, а не с реализациями.

Компромисс. Две схемы — это два места для правки и постоянный вопрос «а по какой из них проверять здесь?». Сквозной пропуск неизвестных ключей вдобавок означает, что опечатка в имени поля не будет замечена: она просто проедет насквозь.

10. Аддитивная гарантия и управляемый Markdown

Единственное место записи на диск — и обещание, которое оно держит перед твоими файлами.

Ключевая мысль: аддитивная запись никогда не перезаписывает чужой файл

У Тимура остался страх, знакомый каждому: «а вдруг оно затрёт мой AGENTS.md?». Прежде чем запускать что-либо, Тимур пошёл искать не обещание в документации, а свойство одной функции. И нашёл.

applyEmitResult — единственная часть движка, которая пишет на диск. И она аддитивна: создаёт файлы и каталоги, никогда не удаляет и никогда не перезаписывает существующий файл, если явно не передан force: true. Операция, которая привела бы к перезаписи, возвращается как skipped — то есть о ней сообщают, а не делают её молча.

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

Второй случай сложнее: файл уже твой. Корневой AGENTS.md пишешь ты, а движку надо добавить туда свой блок политик. Здесь работает управляемый Markdown: движок ставит вокруг своего куска пару меток-ограждений — <!-- dz:policies BEGIN --> и <!-- dz:policies END --> — и трогает только то, что между ними.

Четыре свойства этого приёма, каждое отвечает на свой страх:

  1. Твои байты вне ограждения сохраняются буква в букву. Заметки команды остаются как были.
  2. Повторный вызов идемпотентен — второй прогон не добавляет второй блок.
  3. Соседние блоки независимы: dz:policies и dz:skills не мешают друг другу.
  4. Сломанное состояние меток отвергается с названной причиной, а не «додумывается». Если разметка поехала, движок говорит об этом, а не угадывает границы.

Попробовать безопасно можно так — режим проверки ничего не пишет:

dz agents-sync --check --json

Коды возврата у него разные и это важно: 0 — синхронизировано, 1 — расхождение, 3неубедительно (источник нечитаем). Третий код — отдельная честность: «не смог проверить» это не то же самое, что «проверил и всё хорошо».

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

11. Пресеты: ответ на вопрос «какой набор навыков ставить»

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

Ключевая мысль: пресет — это именованный список навыков

Когда Тимур первый раз запускал dz init, он застрял на простом вопросе: навыков десятки, а какие ставить-то? Ответ на этот вопрос вынесен в отдельный пакет.

Пресет — это именованный список навыков. Три поля: name (то, что ты пишешь после --preset), description (одна строка для человека) и skills (идентификаторы). Всё. Никакой логики.

Измерено по harness-presets/src/presets.ts (grep -c "^ name: '"): четырнадцать пресетов — meta, qe-engineer, bto, reasoning, health, keysarium, p-replicator, feature-adr, devops, web3, mcp, academic, news, pm.

Почему это отдельный пакет, а не константа внутри команды init. Три причины, и все три практические:

  1. Это данные, а не логика. В package.json пакета dependencies пустое — ему не от кого зависеть.
  2. Список читает не только dz init. Его же импортирует MCP-сервер из секции 12 — а он вообще не знает про командную строку.
  3. Набор навыков меняется чаще, чем логика установки. Отдельная версия — отдельный ритм.

Одна честная деталь, которая ломает наивную картину. У пресета есть необязательное поле toolkit. Оно значит: этот набор в основном обслуживается отдельным npx-инструментом, чьи навыки лежат внутри его собственных шаблонов, а не в пакетах @dzhechkov/skills-*. dz init --preset <имя> поставит только ту часть, что есть в пакетах навыков; остальное требует npx <инструмент> init — и CLI об этом говорит, когда навыков не хватило. Пресет не притворяется полным.

И честная дыра, которую Тимур нашёл сразу. У harness-presets нет README — ноль символов документации. Единственный правдивый источник о том, что внутри пресета, — сам файл src/presets.ts. Это неудобство, а не тайна: пакет опубликован, страница на npm существует, но открывается она пустой. Отмечаем как есть.

Компромисс. Курируемый список — это чужой вкус: он экономит тебе выбор ровно до того момента, пока твой сценарий совпадает с чьим-то. Дальше приходится ставить навыки поштучно. И четырнадцать имён — это четырнадцать вещей, которые надо где-то прочитать, а README у пакета нет.

12. MCP-мост: движок без командной строки

Тот же движок, но дверь другая: не терминал, а протокол, по которому агент зовёт инструменты сам.

Ключевая мысль: MCP-сервер отдаёт операции движка без командной строки

Помнишь вывод из секции 4: логика живёт в harness-core, а dz — только дверь? Вот доказательство, что дверь действительно не одна, и Тимур упёрся в него сразу, как только к движку понадобилось подключить агента.

@dzhechkov/mcp-server-tools — сервер MCP. MCP (Model Context Protocol) — открытый протокол, по которому агент обнаруживает и вызывает инструменты сам, без терминала и без разбора текстового вывода. Пакет отдаёт по нему операции движка.

Что он предлагает:

  • skill_list — перечислить навыки в настроенном каталоге;
  • skill_get — прочитать один SKILL.md вместе с разобранным frontmatter;
  • skill_compile — скомпилировать навык под платформу (claude, codex, opencode, hermes);
  • harness_verify — скомпилировать и структурно проверить;
  • claim_check_text — проверить сырой текст до того, как ты его напишешь.

Подключается тремя строчками в .mcp.json проекта: команда dz-mcp-server-tools, переменная окружения DZ_SKILLS_DIR (по умолчанию .claude/skills), транспорт — стандартный ввод-вывод. Сервер ещё и импортируемый: createDzMcpServer() возвращает готовый объект для встраивания или теста.

Один инструмент стоит отдельного взгляда. claim_check_text закрыт по отказу: пустой текст, текст из одних пробелов или из одних невидимых символов — это ошибка, а не «проверка пройдена». Логика ровно та, что и с кодом возврата 3 из секции 10: проверка, которой ничего не дали, ничего и не проверила. А чистый результат означает «не найдено непомеченных количественных утверждений», а не «твой текст верен» — граница обещания названа прямо в README.

Компромисс — вот он, ради него эта секция. Сравни две двери на одну и ту же логику.

*Сильные стороны MCP-моста:* агент зовёт инструмент напрямую, без разбора текстового вывода; список инструментов и их аргументы обнаруживаются самим протоколом; сервер встраивается в тест как обычный объект.

*Слабые:* ещё один процесс в системе, который надо настроить и держать живым; набор инструментов уже набора команд dz — по протоколу отдано пять операций, а команд у CLI десятки; отладка идёт через журналы протокола, а не через ваш привычный терминал.

*Как выбирать:* человеку за клавиатурой — CLI; агенту, который должен решать сам, что вызвать, — MCP. Это выбор двери, а не выбор движка: считает всё равно harness-core.

13. Второе ядро: keysarium-core и честная граница

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

Ключевая мысль: keysarium-core не объявляет зависимости на движок

Вернёмся к догадке, которую Тимур не подтвердил в секции 2. Он предположил, что keysarium-core — оркестратор над остальным движком. Измерение сказало иначе.

Факт: поле dependencies в keysarium-core/package.json пустое. Ни на core, ни на memory, ни на harness-core. Ноль объявленных зависимостей на движок.

При этом README описывает шесть модулей, звучащих как верхний этаж: governance (структурные правила и человеческие чекпоинты), memory (обучение между запусками), orchestration (координация агентов и маршрутизация моделей), verification (цепочка свидетельств и аудит), trust-tiers (четыре уровня доверия к артефакту), platform (генерация конфигураций под платформы).

Так оркестратор он или нет? Разгадка — в схеме архитектуры из его же README: потребители (@dzhechkov/keysarium, @dzhechkov/skills-bto) объявляют keysarium-core одноранговой зависимостью (peer dependency). Одноранговая — это «я работаю с этим пакетом, но приносишь его ты, и экземпляр должен быть один». Стрелки идут к keysarium-core, а не от него — и там же сказано прямо: у него ноль зависимостей на оба потребителя.

Значит, это не третий этаж движка, а второе, параллельное ядро. Первое ядро (harness-core) отвечает на вопрос «как разложить навыки и вести обучение». Второе — на вопрос «как провести многоагентный конвейер с проверками и уровнями доверия». Они не встроены друг в друга, и слово core в двух именах говорит только о роли внутри своей башни.

Приём, который отсюда стоит унести, важнее самого факта. Тимур сделал вывод из названия, а проверил по package.json — и вывод не подтвердился. Это нормальный ход работы: рамка README рассказывает замысел, package.json фиксирует связь. Когда они расходятся, не выбирай одно из двух — назови расхождение и объясни его. Здесь объяснение нашлось: одноранговая зависимость у потребителей.

Компромисс. Одноранговая зависимость даёт независимость и переиспользование, но перекладывает на потребителя обязанность принести совместимую версию; конфликт версий станет его проблемой. А два ядра означают два словаря на один проект — и человека, который однажды спутает, какое из них «то самое».

14. Своя карта: как спуститься к любой команде

Приём вместо карты: пять шагов, которыми ты найдёшь реализацию любой команды dz без подсказки.

Ключевая мысль: спуск от команды к модулю — повторяемый приём

Тимур закрыл заявку. Но полезнее заявки оказался приём: он теперь умеет спуститься к любой команде, а не только к teach. Забери его себе — карта устареет, приём нет.

Пять шагов, всегда одни и те же:

  1. Найди ветку. grep -n "case '<имя команды>'" packages/@dzhechkov/harness-cli/src/cli.ts — вот твоя дверь.
  2. Возьми имя функции. Ветка возвращает вызов вида cmd…. Это ещё командная строка: разбор аргументов и печать, никаких решений о смысле.
  3. Найди границу. Внутри cmd… ищи первый вызов, импортированный из @dzhechkov/harness-core. Вот здесь заканчивается CLI и начинается движок.
  4. Открой модуль. Функция живёт в packages/@dzhechkov/harness-core/src/<модуль>.ts. Прочитай сигнатуру и комментарии над ней: в этом коде они несут причины, а не пересказ.
  5. Прочитай тест рядом. В harness-core/test/ лежит 250 файлов. Тест на модуль отвечает на вопрос, на который не отвечает код: какое свойство здесь обязано держаться.

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

Теперь твоя очередь — это единственное задание курса без правильного ответа. Возьми команду dz, которой ты пользуешься чаще всего, и пройди по ней пять шагов до конца. Выпиши для себя три строки:

  • в каком модуле harness-core она заканчивается;
  • чистый это модуль или он трогает диск (проверь импорты node:fs, как в секции 3);
  • какое свойство защищает лежащий рядом тест.

Три строки — и у тебя на руках собственная карта того куска движка, который тебе действительно нужен. Не всей шестёрки пакетов: никто не держит в голове весь движок, держат приём спуска.

Компромисс. Приём даёт глубину по одной вертикали и ничего не говорит про соседей: пройдя teach, ты не узнаешь, как устроен init. Он также опирается на нынешнюю форму cli.ts — большой switch в одном файле; переедет разбор команд в другую структуру, и первый шаг придётся переписать. Зато остальные четыре переживут переезд.

Частые вопросы

Мне надо ставить эти шесть пакетов руками?

Нет. Ты ставишь одну командную строку, а движок приезжает её зависимостями. Курс нужен не для установки, а для понимания: когда что-то ведёт себя странно, чинить придётся в одном из этих шести пакетов, и надо знать, в каком.

Почему курс так настаивает на чтении package.json?

Потому что это единственный источник, который нельзя переспорить и который не устаревает вместе с текстом README. В секции 2 догадка про keysarium-core как оркестратор не подтвердилась именно измерением, и в секции 13 нашлось честное объяснение — одноранговая зависимость у потребителей.

Что такое «чистое ядро» и «грязная оболочка» простыми словами?

Чистая функция получает данные аргументом и возвращает данные — она не читает файлы, не запускает процессы, не смотрит на часы. Грязная оболочка — тонкий слой, который единственный умеет всё это делать и передаёт результат внутрь. В harness-core измерено: 134 файла в src, 54 трогают файловую систему, процессы или ОС, остальные 80 — нет.

Если запись атомарная, откуда всё-таки берутся потерянные уроки?

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

Почему хранилище по умолчанию — обычный JSON-файл, а не база данных?

Потому что нативный модуль требует компилятора и конкретной версии Node: как обязательная зависимость он ломал бы установку у части пользователей. Каскад пробует более быстрый бэкенд и откатывается к гарантированному чистому JavaScript, если тот недоступен. Тяжёлое хранилище остаётся возможностью, а не условием входа.

Может ли dz затереть мой AGENTS.md?

Запись живёт ровно в одном месте движка и она аддитивна: создаёт, не удаляет и не перезаписывает существующий файл без явного force. В чужом файле движок трогает только собственный блок между метками dz:policies, а твои байты вне него сохраняются буква в букву. Проверить, ничего не записывая: dz agents-sync --check --json.

Где смотреть исходники и версии пакетов движка?

Исходники — в публичном репозитории github.com/djd1m/dz-harness, каталог packages/@dzhechkov/. Опубликованные версии — на npmjs.com у каждого пакета. У harness-presets страница на npm есть, но README пакет не поставляет: правду про состав пресетов читай в src/presets.ts.

Я не помню карту движка. Это провал?

Нет, и запоминать её не надо. Курс заканчивается приёмом, а не картой: ветка case в cli.ts, функция cmd…, первый вызов из harness-core, модуль в src, тест рядом. Пять шагов работают для любой команды, а карта устаревает с каждым релизом.