Handbooks
Концепции

Архитектура

Одиннадцать пакетов в четырёх слоях, строго однонаправленные зависимости и границы, которые делают детерминированную половину переиспользуемой саму по себе.

Слои

Слои пакетов: точки входа, возможности, движки, фундамент
СлойПакетыЗадача
Точки входаcli, studioТо, что запускает человек или контейнер
Возможностиpipeline, renderer, skill, planner, patcher, resyncПо одной задаче на пакет, используются независимо
Движкиanalyzer, llmДве вещи, на которых построено всё остальное
ФундаментcoreМодель данных, реестр конфигурации, утилиты

Зависимости всегда направлены только вниз:

cli → pipeline / renderer / skill / planner / patcher / resync → analyzer / llm → core

Три правила, которые поддерживают здоровье

1. Однонаправленные зависимости, принудительно

core не импортирует ничего внутреннего. Никто не импортирует cli. Цикл или импорт вверх роняет pnpm check:workspace, который также проверяет, что ссылки TypeScript-проектов каждого пакета в точности зеркалируют его зависимости в package.json — отсутствующая ссылка заставляет tsc -b собирать в неправильном порядке, а сборка из корня это скрывает.

2. Изоляция LLM — это граница пакетов, а не соглашение

Только llm, pipeline, planner и resync могут обращаться к модели, и только через интерфейс ChatClient:

interface ChatClient {
  readonly model: string;
  complete(prompt: string, options?: ChatOptions): Promise<ChatResult>;
}

analyzer, renderer, skill и patcher вообще не зависят от @handbooks/llm. Они полностью детерминированы и переиспользуемы без какого-либо LLM поблизости. Именно поэтому render, skill, validate, apply и rollback можно свободно запускать в CI.

По этой же причине весь тестовый набор работает офлайн: один шов — один mock.

3. Граница рендерера — это тип

HandbookModel (определён в core) — единственное, о чём знает рендерер. Он никогда не читает внутренности конвейера.

interface HandbookModel {
  title: string;
  lang: NarrateLang;
  skeleton: Skeleton;
  cards: Record<string, FileCard>;
  assignment: Assignment;
  organization: Organization;
  narration: Narration;
  registers: RegisterEntry[];
  provenance?: { commit?: string; generatedAt: string };
}

Любой производитель, способный заполнить HandbookModel, бесплатно получает рендеринг, упаковку в skill и планирование. Если вы хотите генерировать руководство каким-то другим способом — вот весь контракт, который нужно выполнить.

Поток данных

source tree
   │  analyzer — tree-sitter WASM, one adapter per language

phase1/graph.json · functions.csv · graph.dot · dropped-calls.json · scan-coverage.json
   │  pipeline 2a — cards (batched LLM, three-tier degradation, resumable)

phase2/cards/<rel>.json + _coverage.json
   │  pipeline 2b — skeleton synthesis (+ doctor loop) + file assignment

phase2/skeleton.yaml + assignment.json
   │  pipeline 2c — call-graph topological order + LLM grouping (flat fallback)

phase2/organization.yaml
   │  pipeline 3 — bottom-up narration + register extraction (content-hash cached)

phase3/narration.json + registers.json
   │  loadHandbookModel()

HandbookModel ──▶ renderer ──▶ handbook/  (md · html/ · handbook.html · agent/ · llms.txt)

                     └──▶ skill ──▶ SKILL.md + references/ (+ coverage.json)

Контракт рабочего каталога: каждая фаза читает только артефакты выше по потоку и пишет только свои собственные, всё валидируется по схеме при чтении с полем version. Любую фазу можно перезапустить отдельно. Падения возобновляются — карточки записываются по батчам, повествование кешируется по хешу содержимого.

Артефакт для человека объясняет; артефакт для агента указывает, где что лежит

Один HandbookModel, два результата с по-настоящему разными задачами — и это разделение и есть замысел, а не деталь упаковки.

Руководства в markdown и HTML написаны, чтобы их читали: проза, порядок, повествовательный стержень. agent/ написан, чтобы по нему искали через grep: symbols.tsv одной строкой отвечает на вопрос «где определён sendPayment», чего никакая проза не делает.

Раньше это была одна и та же проза в двух формах, и цена была вполне конкретной: индекс для агента выходил в 2,1 раза больше человеческого и при этом не содержал ни одного местоположения символа, потому что 42% его объёма составляла модельная проза, скопированная байт в байт с человеческих страниц. Теперь агентская сторона несёт факты и по одной обрезанной строке прозы на файл, а там, где нужно объяснение, каждая страница этапа ссылается на человеческую страницу вместо того, чтобы её дублировать.

Внутри анализатора

Каждый язык реализует один LanguageAdapter: discover, analyze и, опционально, statementSpans. Каждая грамматика — WebAssembly, поэтому установка никогда не компилирует нативный код.

Адаптеры выполняют два прохода на модуль:

  1. Scan — объявления, импорты, классы и методы, а также факты по каждой функции: сигнатура, диапазон строк, асинхронность, декораторы, чтения и записи атрибутов self/this, типизированные параметры и типы атрибутов, выведенные из присваиваний в конструкторе.
  2. Resolve — каждая точка вызова становится типизированным ребром: self_method, self_attr_method, param_method, internal_func, internal_constructor, boundary, boundary_constructor — либо unresolved, которое построитель графа отправляет в карантин в dropped-calls.json с категорией.

Сохраняемый граф всегда содержит только разрешённые, именованные цели вызовов. Именно это делает ребро в нём заслуживающим доверия.

То же правило действует уровнем выше — для файлов целиком. Файл, который адаптер не смог прочитать, на котором упала грамматика или который разобрался с синтаксическими ошибками, записывается в scan-coverage.json с указанием причины, а первые два случая не попадают в scannedFiles, так что ни одна последующая фаза не сможет описать файл, которого парсер не видел.

Nav-pack — это детерминированная ориентировочная сводка, выведенная из графа: свёртки по каталогам, кандидаты в точки входа, веерность, внешние подсистемы. Это единственное представление кодовой базы, которое видит синтезатор скелета, — что держит этот промпт маленьким и заземлённым.

Механизмы качества конвейера

Трёхуровневая деградация карточек (2a). Целый батч → отдельный файл → пофункциональные куски для файлов-переростков. Файлы, которые всё равно не получились, получают честную пустую карточку и перечисляются в _coverage.json. Покрытие полно по построению; промахи видимы, а не безмолвны.

Актор-критик-доктор для скелета (2b). Актор предлагает не более трёх структурных изменений на основе достоверной статистики; три ролевых критика (инженер, архитектор, читатель) рецензируют параллельно; каждое выжившее изменение механически перепроверяется перед применением; затронутые файлы переназначаются. Цикл останавливается при сходимости или после двух раундов без прогресса. Сломанный критик считается за REJECT — упавший рецензент никогда не должен пропускать изменения.

Детерминированные запасные варианты везде (2c, 3). Организация откатывается к порядку графа вызовов. Повествование — к описанию этапа. Сбой извлечения регистров даёт пустой список. Запуск генерации деградирует, но не блокируется.

Кеши по хешу содержимого (3). Проза этапов и системы кешируется в phase3/cache/ с ключом из версии промпта, языка и полного хеша промпта — так что повторные запуски и resync платят только за то, что действительно изменилось.

Конкурентность и безопасность

  • Один запуск на рабочий каталог. generateHandbook и resyncHandbook берут одну и ту же реентерабельную блокировку каталога, поэтому запуск CLI и задача Studio не могут перемежать записи в одни и те же артефакты.
  • Атомарные записи. Каждый артефакт пишется во временный файл и переименовывается. Падение никогда не оставляет полузаписанный файл, на котором подавится следующий запуск.
  • Кооперативная отмена. AbortSignal проверяется между фазами и на каждой контрольной точке батча и протягивается в каждый вызов LLM, так что запросы в полёте прерываются. Прерванный запуск сохраняет то, что успел записать, и не пишет манифест запуска.

Решения, о которых стоит знать

#РешениеПочему
1Только WASM для tree-sitterНоль нативных сборок; один путь загрузки для всех языков; грамматики с фиксированными версиями
2Самописный LLM-клиент на fetchOpenAI-совместимые конечные точки различаются; тонкий клиент с явными повторами лучше зависимости от SDK. Шов интерфейса важнее транспорта
3Один конвейер, две стратегииОтдельные конвейеры для больших/малых репозиториев дублируют адаптеры, критиков, клиентов и рендереры; флаг стратегии убирает около 40% этой поверхности
4Артефакты с валидацией zod и полем versionПовреждённые или отредактированные вручную артефакты громко падают на границе, вместо того чтобы отравлять последующие фазы
5Разделение фактов и прозы в карточкахМодель аннотирует полную инвентаризацию, выведенную из графа. Проза может быть пустой; факты не могут быть неверными
6Однотуровый протокол планировщикаРаботает на любой конечной точке, тривиально мокается, а транскрипт можно инспектировать. Цена — повторная отправка токенов — приемлема в масштабах планировщика
7ESM + tsc -b, без бандлераБиблиотеки поставляют типопроверенный dist/ и .d.ts; композитные ссылки дают инкрементальные сборки без дополнительного инструментария
8Один реестр конфигурацииФлаги, имена переменных окружения, ключи YAML и три генерируемых документа выводятся из одной таблицы, поэтому не могут разойтись

Дальше

На этой странице