Замена YAML-парсера — результаты исследования
Внутренний проектный документ по замене самописного 192-строчного YAML-парсера в
packages/core/src/utils/yaml-parser.ts на полноценную библиотеку, чтобы отложенные
поля mcpServers и hooks из схемы declarative-агента Claude Code могли
без потерь проходить полный цикл обработки в коде subagent / skill / converter.
Дополнение к docs/design/declarative-agents-port.md.
Задача: #4821 . Требуется для реализации
follow-up к PR #4842 .
Фаза 0 — Проверенные источники
| Источник | Версия / Дата | Почему считается авторитетным |
|---|---|---|
~/code/claude-code/src/utils/yaml.ts | старый снэпшот CC (до 2.1.168) | прямой исходник — 15-строчная обертка, указывающая библиотеку |
~/code/claude-code/src/utils/frontmatterParser.ts | тот же снэпшот | прямой исходник — 370-строчный разделитель frontmatter + восстановление в 2 прохода |
/private/tmp/cc-2.1.168/claude.strings | извлечено из CC 2.1.168 | авторитетный источник для текущего поведения — строки содержат обфусцированные имена символов, но включают JSON-схему и текст сообщений об ошибках |
packages/core/src/utils/yaml-parser.ts (этот репо) | HEAD ветки lazzy/gifted-hamilton-684741 | заменяемый парсер |
live node -e пробы против yaml@2.8.1 в этом дереве | 2026-06-08 | эмпирическое поведение безопасности — якоря, merge keys, !!js/function, billion-laughs, maxAliasCount (результаты встроены в Фазу 4) |
Метки уверенности: C подтверждено прямыми доказательствами; I выведено из нескольких подтвержденных фактов; O открытый вопрос.
Фаза 1 — Какую YAML-библиотеку использует CC?
Ответ: yaml (eemeli/yaml), а НЕ
js-yaml. Подтверждено дословным чтением ~/code/claude-code/src/utils/yaml.ts:
export function parseYaml(input: string): unknown {
if (typeof Bun !== 'undefined') {
return Bun.YAML.parse(input);
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
return (require('yaml') as typeof import('yaml')).parse(input);
}- Библиотека: npm-пакет
yaml. C - API: метод верхнего уровня
.parse(input). Использует схему по умолчанию пакета (которая представляет собой YAML 1.2core— надмножество JSON, без JS-расширений). C - Быстрый путь для Bun: при запуске под Bun, CC использует
Bun.YAML.parse(), чтобы избежать бандлинга ~270 КБ YAML-парсера. C Не относится к qwen-code (мы не таргетируем рантайм Bun). - Режим схемы: ЯВНО не установлен нигде в CC. Полагается на поведение
yamlпо умолчанию, а также на валидацию zod на уровне потребителя (DL7,gS8,TKO/_uсогласноdocs/design/declarative-agents-port.md). C
Почему yaml, а не js-yaml
| Характеристика | js-yaml 4.x | yaml (eemeli) 2.x |
|---|---|---|
| Схема по умолчанию | DEFAULT_SAFE_SCHEMA (начиная с 4.x) — безопасная; старые версии имели DEFAULT_FULL_SCHEMA с JS | core (спецификация YAML 1.2) — только JSON-типы |
Тег !!js/function | НЕ поддерживается в 4.x (был в 3.x) | Никогда не поддерживался |
| Защита от billion-laughs | Отсутствует (ручная ответственность) | Встроенный maxAliasCount: 100 по умолчанию |
Merge keys (<<) | Поддерживаются (нужно отключать через MERGE_SCHEMA или фильтрацию) | Отключены по умолчанию, включаются через { merge: true } |
| Уже есть в зависимостях qwen-code? | js-yaml@4.1.1 ✓ | yaml@2.8.1 ✓ (уже импортируется в skill-manager) |
Оба варианта являются разумным выбором в 2026 году, но в исходном техническом задании рекомендовалось использовать FAILSAFE_SCHEMA / CORE_SCHEMA из js-yaml. Мы отходим от этих рекомендаций по трем конкретным причинам:
- Паритет с CC. Весь смысл портирования схемы frontmatter из CC заключается в том, чтобы пользователи могли просто поместить файл агента CC в
.qwen/agents/, и он парсился идентичным образом. Использование того же парсера, что и в CC, минимизирует расхождения в граничных YAML-конструкциях (multi-doc streams, flow vs block scalars, обработка тегов). yamlуже используется напрямую вskill-manager.ts— см.packages/core/src/skills/skill-manager.ts:13(import * as yaml from 'yaml'). Стандартизация наyamlустраняет один из двух дублирующихся YAML-стеков в одном пакете. C (результат grep задокументирован в Фазе 6).- Более безопасные значения по умолчанию, чем в
js-yaml. ВстроенныйmaxAliasCountвyamlблокирует billion-laughs без ручной настройки; merge keys отключены по умолчанию; произвольные теги становятся литеральными строками сYAMLWarning, а не вызывают вызываемые резолверы. Эмпирические данные приведены в Фазе 4.
Если будущий мейнтейнер захочет убрать зависимость от yaml и унифицировать всё на js-yaml, миграция будет механической: замените yaml.parse / yaml.stringify на jsYaml.load(s, { schema: jsYaml.CORE_SCHEMA }) / jsYaml.dump. Обе библиотеки выдают одинаковый результат для тех 100% подмножества, которое фактически используют CC и qwen-code (пары ключ-значение, списки, вложенные словари, скалярные булевы значения и числа). Если этот вопрос возникнет, зафиксируйте это решение отдельно.
Фаза 2 — Пайплайн парсинга frontmatter (CC)
Файл ~/code/claude-code/src/utils/frontmatterParser.ts содержит 370 строк. Ключевые находки:
| Шаг | Логика | Источник |
|---|---|---|
| Поиск разделителя | Regex /^---\s*\n([\s\S]*?)\n---\s*\n?/ — открывается на колонке 0, тело нежадное, закрывающий --- должен быть на отдельной строке | frontmatterParser.ts:~123 (номера строк из старого снэпшота; считать приблизительными) C |
| Парсинг (проход 1) | Вызов parseYaml(body). При успехе → вернуть распарсенный объект + остаток контента. | тот же файл, начало try-блока C |
| Восстановление (проход 2) | При YAMLException, обходить строки, автоматически заключать в кавычки значения, похожие на даты/двоеточия/спецсимволы, повторить parseYaml один раз. | строки ~85–121 в старом снэпшоте C (нормализация tab → 2 spaces, эвристика ISO-дат, ловушка с двоеточием) |
| Обработка ошибки (fallthrough) | Оба прохода не удались → логировать через logForDebugging, вернуть { data: {}, content: text }. Агент загружается с пустым frontmatter. | конец функции C |
| Телеметрия | Обернуто дальше по стеку — события tengu_frontmatter_shadow_unknown_key / _mismatch срабатывают из ug5.agent (схема Ig5) | claude.strings:308120, 309074, 309076 (перекрестные ссылки в Фазе 1 docs/design/declarative-agents-port.md) |
Следствие для qwen-code: нам НЕ нужно клонировать восстановление в 2 прохода. subagent-manager.ts в qwen-code уже применяет более строгую семантику “выбрасывать исключение при некорректном frontmatter на верхнем уровне” для своего загрузчика (см. parseSubagentContent), а восстановление в 2 прохода специально реализовано для прощения ошибок в старых, отредактированных вручную файлах агентов CC. Перенос более строгого подхода вполне допустим; нам просто нужно не ронять весь загрузчик, если вложенные поля некорректны. См. Фазу 5 для подхода warn-and-drop.
Фаза 3 — Вложенная валидация через zod (CC)
Релевантные валидаторы CC согласно Фазе 1 docs/design/declarative-agents-port.md + перекрестная проверка бинарных строк:
mcpServers (символ CC gS8 / JSON-shadow jL7)
mcpServers: z.union([
z.string(), // ссылка на имя сервера
z.record(z.string(), McpServerConfigSchema()), // инлайн { name: spec }
])McpServerConfigSchema() (из ссылки claude.strings:124–135) представляет собой
дискриминированное объединение по полю type:
type | Обязательные поля | Примечания |
|---|---|---|
"stdio" | command: string, args?: string[] | Плюс env?: Record<string,string>, cwd?: string |
"sse" | url: string | Плюс headers?: Record<string,string> |
"http" | url: string | Плюс headers?, method? |
"websocket" | url: string | паритет qwen-code неизвестен — отложить до необходимости |
"sdk" | варьируется | Внутреннее использование CC; нам НЕ нужно поддерживать |
"claudeai-proxy" | варьируется | Внутреннее использование CC; нам НЕ нужно поддерживать |
Для qwen-code v1: валидировать как Record<string, unknown> (мягкий стиль DL7) и позволить последующему слиянию в Config.getMcpServers() выполнить приведение формы. В qwen-code уже есть класс MCPServerConfig с дискриминацией по type — мы переиспользуем этот конвертер вместо дублирования zod-схемы. См. Фазу 4 плана runtime-wiring в docs/design/declarative-agents-port.md.
hooks (символ CC TKO / _u)
hooks: Partial<Record<HookEvent, HookMatcher[]>>
HookMatcher: { matcher?: string, hooks: HookConfig[] }
HookConfig (дискриминированное объединение по `type`):
- { type: 'command', command: string, timeout?: number, ... }
- { type: 'prompt', prompt: string, ... }
- { type: 'agent', agent: string, ... }
- { type: 'http', url: string, headers?, ... }Согласно перекрестной проверке строк, ключи событий хуков представляют собой тот же набор, который уже поддерживает qwen-code: PreToolUse, PostToolUse, UserPromptSubmit,
SessionStart, SessionEnd, Stop, SubagentStart, SubagentStop,
Notification — плюс несколько событий, специфичных только для qwen-code (TodoCreated, TodoCompleted), которых нет в CC.
Для qwen-code v1: валидировать как Record<string, unknown> (мягко), а затем передавать существующим валидаторам SessionHooksManager из qwen-code, которые уже реализуют форму HookDefinition[] для каждого события (см. packages/core/src/hooks/types.ts:207–211 согласно runtime-mapping из Фазы 1).
Почему оба валидатора используют z.unknown() на уровне shadow-схемы Ig5
Ig5 — это телеметрическая shadow-схема — она генерирует события tengu_frontmatter_shadow_unknown_key, если YAML-ключ отсутствует в известном наборе, и события _mismatch, если известный ключ имеет неправильный тип. Она намеренно использует z.unknown() для mcpServers и hooks, потому что Ig5 выполняется на этапе ПАРСИНГА и выдавала бы ложные события mismatch для каждой inline-спецификации mcpServers. Реальная валидация делегирована:
gS8(дляmcpServers) — вызывается во время регистрации агента изDL7черезsafeParseдля каждого элементаTKO(дляhooks) — вызывается во время срабатывания хука из_u().safeParseЭта ленивая валидация — тот самый подход, который должна перенять qwen-code: сделать парсер frontmatter максимально терпимым (аналогz.unknown()в TS), а валидацию выполнять в месте использования. Попытка перенести полное дерево zod вSubagentConfigзаставила бы нас также импортировать классMCPServerConfigи типHookDefinitionиз qwen в слой, где они сейчас не находятся, и потребовала бы создания фиктивных валидаторов дляtype: 'sdk'/type: 'claudeai-proxy', которые мы на самом деле не поддерживаем.
Фаза 4 — Безопасность
Эмпирическая проверка настроек по умолчанию yaml@2.8.1 в дереве qwen-code:
Результаты проверки
$ node -e "const y=require('yaml'); console.log(y.parse('a: 1').constructor.name, y.parseDocument('a: 1').schema?.name)"
Object core→ схема по умолчанию — 'core' (надмножество JSON из YAML 1.2). C
$ node -e "const y=require('yaml'); console.log(y.parse('!!js/function \"function(){}\"'))"
function(){}
(node:18525) [TAG_RESOLVE_FAILED] YAMLWarning: Unresolved tag: tag:yaml.org,2002:js/function→ тег !!js/function НЕ выполняется. Значение преобразуется в буквальную строку "function(){}" (не в вызываемый объект функции) и выдает неустранимое предупреждение YAMLWarning. Злоумышленник не может достичь RCE через этот вектор. C
$ node -e "const y=require('yaml'); const bomb = 'a: &a [hi,hi]\nb: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]\nc: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]\nd: [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c]'; try { y.parse(bomb) } catch(e){ console.log('REJECTED:', e.message) }"
REJECTED: Excessive alias count indicates a resource exhaustion attack→ расширение псевдонимов / billion-laughs ОТКЛОНЯЕТСЯ по умолчанию. Библиотека поставляется с maxAliasCount: 100 (неудачный парсинг подсчитывает 1+10+100 = 111 псевдонимов). C
$ node -e "const y=require('yaml'); console.log(JSON.stringify(y.parse('defaults: &d\n a: 1\nfoo:\n <<: *d\n b: 2')))"
{"defaults":{"a":1},"foo":{"<<":{"a":1},"b":2}}→ ключ слияния (<<) по умолчанию парсится как буквальная строка ключа, а НЕ раскрывается. Парсер << включается опционально через { merge: true }. Мы НЕ будем его включать. C
$ node -e "const y=require('yaml'); const yml='mcpServers:\n filesystem:\n type: stdio\n command: node\n args:\n - /path/to/server.js'; console.log(JSON.stringify(y.parse(yml), null, 2))"
{
"mcpServers": {
"filesystem": { "type": "stdio", "command": "node", "args": ["/path/to/server.js"] }
}
}→ вложенные mcpServers в формате CC корректно парсятся в глубоко вложенный объект/массив. C
Сводка по безопасности
| Вектор | Поведение yaml@2.8.1 по умолчанию | Необходимые действия в qwen-code |
|---|---|---|
| Выполнение произвольного JS | Невозможно — нет eval | Нет |
Тег !!js/function | Преобразуется в буквальную строку + предупреждение | Нет |
| Billion laughs | Отклоняется (maxAliasCount: 100) | Нет — оставить по умолчанию |
Ключи слияния (<<) | Обрабатываются как буквальный ключ | Нет — оставить по умолчанию (НЕ передавать merge: true) |
| Якоря / псевдонимы (обычное использование) | Разрешены, полезны для данных в формате CC | Нет |
| Произвольные неизвестные теги | Строка + YAMLWarning | Опционально перенаправлять предупреждения в логгер (см. Фазу 6) |
Вывод: стандартное поведение пакета yaml уже безопаснее того, что изначально требовалось в техническом задании через FAILSAFE_SCHEMA из js-yaml. Вызов блокировки схемы не требуется.
Фаза 5 — Семантика восстановления
CC выбирает мягкий подход “предупредить и отбросить” на каждом уровне:
- Парсер YAML выбрасывает ошибку → парсер frontmatter логирует её и возвращает
{}(пустые данные) - Поле имеет неверную структуру (например,
mcpServers: "this is a string") →safeParseзавершается с ошибкой → поле удаляется из итоговой конфигурации - Поле имеет почти неверную структуру (например, отдельный элемент
mcpServersявляется строкой, когда схема ожидает объект) →safeParseдля каждого элемента удаляет только этот элемент, сохраняя остальные
qwen-code уже реализует подход “предупредить и отбросить” для каждого поля для permissionMode, maxTurns, color, effort (см. packages/core/src/subagents/agent-frontmatter-schema.ts). Мы расширяем этот же паттерн на mcpServers и hooks.
Что мы НЕ копируем из CC:
- 2-проходное восстановление YAML с автоматическим экранированием. Это мертвый груз для qwen-code — мы новый проект, у нас нет устаревших файлов frontmatter, отредактированных вручную, которые нужно прощать. Чистая ошибка полезнее, чем угаданная переинтерпретация.
- События телеметрии
tengu_*. Заменены собственным логгером qwen-code / любым слоем телеметрии, который использует остальная часть загрузчика.
Фаза 6 — Рекомендации для qwen-code
Выбор библиотеки
- Использовать
yaml@^2.8.1(уже является транзитивной зависимостью — повысить до прямой зависимости вpackages/core/package.json, чтобы не сломаться в более строгих режимах резолвинга; также это позволит зафиксировать мажорную версию). - Использовать схему по умолчанию (
core), без флага схемы. - Не передавать
{ merge: true }. Не включать никакие нестандартные опции. - Для детерминированного вывода
stringify(снапшоты тестов) передавать{ lineWidth: 0, defaultStringType: 'PLAIN' }вyaml.stringify, чтобы библиотека не переносила длинные строки и не переключалась произвольно на блочное экранирование скаляров в зависимости от длины контента.
Сохраняемая поверхность API
Текущий packages/core/src/utils/yaml-parser.ts экспортирует:
export function parse(yamlString: string): Record<string, unknown>;
export function stringify(
obj: Record<string, unknown>,
options?: { lineWidth?: number; minContentWidth?: number },
): string;Замена сохраняет обе сигнатуры идентичными, поэтому 5 вызывающих сторон (subagent-manager.ts, claude-converter.ts, rulesDiscovery.ts, skill-manager.ts, skill-load.ts) и реэкспорт в index.ts не потребуют никаких изменений в местах вызова.
Набросок реализации:
import * as yaml from 'yaml';
export function parse(yamlString: string): Record<string, unknown> {
const parsed = yaml.parse(yamlString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return {};
}
export function stringify(
obj: Record<string, unknown>,
options?: { lineWidth?: number; minContentWidth?: number },
): string {
return yaml.stringify(obj, {
lineWidth: options?.lineWidth ?? 0,
minContentWidth: options?.minContentWidth ?? 20,
});
}Почему мы приводим не-объектные верхнеуровневые значения к {}: каждый существующий вызывающий код ожидает запись (record). YAML-файл, который парсится в null (пустой файл), ["foo"] (список) или "hello" (голый скаляр), в настоящее время приведет к краху деструктуризации ниже по коду. Возврат {} сохраняет поведение старого самописного парсера на тех же входных данных. Задокументируйте это как преднамеренное защитное ограничение в однострочном комментарии.
Вызывающие стороны, не требующие изменений
| Файл | Использование | Совместимо? |
|---|---|---|
packages/core/src/index.ts:360 | реэкспортирует * из yaml-parser | да — те же имена |
packages/core/src/subagents/subagent-manager.ts:15 | parse, stringify | да |
packages/core/src/extension/claude-converter.ts:26 | parse, stringify | да — round-trip теперь безопасен для mcpServers + hooks (см. Фазу 3) |
packages/core/src/utils/rulesDiscovery.ts:20 | parse as parseYaml | да |
packages/core/src/skills/skill-manager.ts:13 | parse as parseYaml (и import * as yaml from 'yaml' отдельно) | да — и дублирующийся import * as yaml можно удалить в последующем коммите |
packages/core/src/skills/skill-load.ts:11 | parse as parseYaml | да |
Необходимые тестовые фикстуры
Три конкретных YAML-фрагмента, на которых ломается текущий самописный парсер и которые должна обрабатывать замена (по одному на каждую вложенную структуру):
# Фикстура 1 — mcpServers (запись записей)
mcpServers:
filesystem:
type: stdio
command: node
args:
- /path/to/server.js
env:
DEBUG: '1'
github:
type: http
url: https://mcp.example.com/github
headers:
Authorization: 'Bearer xxx'# Фикстура 2 — hooks (запись массивов записей, два уровня вложенности под именем события)
hooks:
PreToolUse:
- matcher: 'Read|Write'
hooks:
- type: command
command: echo before
timeout: 5000
PostToolUse:
- matcher: '*'
hooks:
- type: command
command: echo after# Фикстура 3 — смешанные мелкие + глубокие, плюс всё, что уже поддерживает PR #4842
name: agent-x
description: test
permissionMode: acceptEdits
maxTurns: 5
color: cyan
tools:
- Read
- Write
mcpServers:
filesystem:
type: stdio
command: node
hooks:
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: logТесты, которые необходимо изменить
В packages/core/src/utils/yaml-parser.test.ts внизу (строки 200–227) есть 2 “закрепляющих теста” с заголовком known limitations — nested YAML (pin until js-yaml lands). Замена ДОЛЖНА превратить их в позитивные утверждения для парсинга вложенных структур:
it('parses array-of-records', () => {
const yaml =
'mcpServers:\n - filesystem:\n type: stdio\n command: node';
expect(parse(yaml)).toEqual({
mcpServers: [{ filesystem: { type: 'stdio', command: 'node' } }],
});
});
it('parses record-of-records', () => {
const yaml = 'hooks:\n PreToolUse:\n - matcher: Read';
expect(parse(yaml)).toEqual({
hooks: { PreToolUse: [{ matcher: 'Read' }] },
});
});Эти два утверждения плюс три фикстуры выше являются критерием приемки для Фазы 2 плана реализации. Всё остальное (граничные случаи экранирования, булевы значения в кавычках и без, числовые строки) — это регрессионное покрытие из существующего набора тестов, и оно должно проходить без изменений.
Проверка паритета round-trip
Существующий тест should maintain round-trip integrity for escaped strings (строки 111-129) прогоняет 7 строк через stringify → parse. Стандартный stringify из yaml выдает немного другой результат, чем самописный форматтер (в некоторых случаях более агрессивное экранирование, другие escape-последовательности). Два допустимых исхода:
- Адаптировать тестовые фикстуры для проверки поведения под новым парсером — важно свойство round-trip (
parse(stringify(x)) === x), а не побайтово идентичный вывод YAML. - Оставить побайтово идентичные утверждения и дать им явно упасть, затем обновить их, чтобы они в точности отражали вывод
yaml. Так легче ревьюить дифф.
Рекомендация: вариант 1 — изменить утверждения на основанные на свойствах (expect(parse(stringify(obj))).toEqual(obj)), так как побайтово идентичный вывод YAML не является задокументированным контрактом модуля.
Критические изменения для вызывающих сторон — не ожидаются, но проверьте
subagent-manager.tsповторно сериализует распарсенный объект обратно в YAML для путиsaveSubagent. С новым парсеромmcpServersиhooksбудут чисто проходить round-trip. ОбновитеNESTED_FIELDS_NOT_ROUND_TRIPPABLEвclaude-converter.ts(Фаза 3 реализации), чтобы убрать эти два имени поля.skill-manager.tsуже импортируетyamlнапрямую (отдельно от самописного парсера). Как толькоyaml-parser.tsтоже начнет использоватьyaml, дублирующийся импорт можно будет удалить в рамках небольшого последующего коммита — это вне скок данной задачи.
Риски миграции
Низкие. Все 5 мест вызова деструктурируют Record<string, unknown> — тип возвращаемого значения тот же. Единственные ожидаемые падения — это 2 намеренных pin-теста “garbles”; они известны, и мы намеренно их инвертируем. Более широкое покрытие регрессиями обеспечивается существующими наборами тестов в packages/core/src/subagents/, packages/core/src/skills/ и packages/core/src/extension/.
Открытые вопросы
| # | Вопрос | Блокирующий? | Путь решения |
|---|---|---|---|
| Q1 | Нужен ли yaml.parse явный логгер для перенаправления YAMLWarning (например, Unresolved tag) в логгер qwen-code вместо process.emitWarning? | Нет — отложить | Если логи в CI станут слишком шумными, пробросить { logLevel: 'silent' } или кастомный колбэк onWarning. Не критично для v1. |
| Q2 | Должен ли parse() продолжать возвращать {} для пустой строки / null-документа YAML, или выбрасывать ошибку? | Нет — сохранить текущее поведение | Текущая самописная реализация возвращает {}; мы это сохраняем. Добавить регрессионный тест, фиксирующий этот выбор. |
| Q3 | Если mcpServers некорректен на верхнем уровне (например, mcpServers: "string"), должен ли весь агент падать при загрузке, или загружаться с отброшенным полем? | Да — определяет стратегию warn-and-drop на этапе 3 реализации | Решение: отбросить поле, вывести предупреждение в консоль (паритет с CC DL7 согласно этапу 3 в docs/design/declarative-agents-port.md). |
| Q4 | То же, что и Q3, но для hooks: отбросить поле, событие или только отдельный матчер? | Да — определяет стратегию warn-and-drop | Решение: отбрасывать всё поле hooks при ошибке формы на верхнем уровне. Гранулярность на уровне событий/матчеров откладывается до будущего PR, если реальным пользователям это понадобится. |
| Q5 | Применимо ли сокращение Bun.YAML.parse из хелпера CC к qwen-code? | Нет | qwen-code не нацелен на рантайм Bun. Пропускаем. |
Статус: исследование завершено, готово к реализации этапа 2 (замена
yaml-parser.ts) и этапа 3 (повторное добавление mcpServers + hooks в
SubagentConfig) согласно docs/design/declarative-agents-port.md.