Архитектура
Одиннадцать пакетов в четырёх слоях, строго однонаправленные зависимости и границы, которые делают детерминированную половину переиспользуемой саму по себе.
Слои
| Слой | Пакеты | Задача |
|---|---|---|
| Точки входа | 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, поэтому установка никогда не
компилирует нативный код.
Адаптеры выполняют два прохода на модуль:
- Scan — объявления, импорты, классы и методы, а также факты по каждой функции:
сигнатура, диапазон строк, асинхронность, декораторы, чтения и записи атрибутов
self/this, типизированные параметры и типы атрибутов, выведенные из присваиваний в конструкторе. - 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-клиент на fetch | OpenAI-совместимые конечные точки различаются; тонкий клиент с явными повторами лучше зависимости от SDK. Шов интерфейса важнее транспорта |
| 3 | Один конвейер, две стратегии | Отдельные конвейеры для больших/малых репозиториев дублируют адаптеры, критиков, клиентов и рендереры; флаг стратегии убирает около 40% этой поверхности |
| 4 | Артефакты с валидацией zod и полем version | Повреждённые или отредактированные вручную артефакты громко падают на границе, вместо того чтобы отравлять последующие фазы |
| 5 | Разделение фактов и прозы в карточках | Модель аннотирует полную инвентаризацию, выведенную из графа. Проза может быть пустой; факты не могут быть неверными |
| 6 | Однотуровый протокол планировщика | Работает на любой конечной точке, тривиально мокается, а транскрипт можно инспектировать. Цена — повторная отправка токенов — приемлема в масштабах планировщика |
| 7 | ESM + tsc -b, без бандлера | Библиотеки поставляют типопроверенный dist/ и .d.ts; композитные ссылки дают инкрементальные сборки без дополнительного инструментария |
| 8 | Один реестр конфигурации | Флаги, имена переменных окружения, ключи YAML и три генерируемых документа выводятся из одной таблицы, поэтому не могут разойтись |