@gian-tiaga/eda 0.8.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/MIGRATION.md +117 -0
  2. package/README.md +324 -71
  3. package/agents/eda-aim-executor/agent.json +15 -0
  4. package/agents/eda-aim-executor/prompt.md +61 -0
  5. package/agents/eda-aim-planner/agent.json +15 -0
  6. package/agents/eda-aim-planner/prompt.md +43 -0
  7. package/agents/eda-aim-verifier/agent.json +15 -0
  8. package/agents/eda-aim-verifier/prompt.md +57 -0
  9. package/agents/eda-commit-context/agent.json +15 -0
  10. package/agents/eda-commit-context/prompt.md +58 -0
  11. package/agents/eda-commit-executor/agent.json +15 -0
  12. package/agents/eda-commit-executor/prompt.md +87 -0
  13. package/agents/eda-merge-worktree-executor/agent.json +15 -0
  14. package/agents/eda-merge-worktree-executor/prompt.md +57 -0
  15. package/agents/eda-review-api/agent.json +15 -0
  16. package/agents/eda-review-api/prompt.md +34 -0
  17. package/agents/eda-review-architecture/agent.json +15 -0
  18. package/agents/eda-review-architecture/prompt.md +34 -0
  19. package/agents/eda-review-code-quality/agent.json +15 -0
  20. package/agents/eda-review-code-quality/prompt.md +34 -0
  21. package/agents/eda-review-correctness/agent.json +15 -0
  22. package/agents/eda-review-correctness/prompt.md +34 -0
  23. package/agents/eda-review-database/agent.json +15 -0
  24. package/agents/eda-review-database/prompt.md +34 -0
  25. package/agents/eda-review-documentation/agent.json +15 -0
  26. package/agents/eda-review-documentation/prompt.md +34 -0
  27. package/agents/eda-review-frontend/agent.json +15 -0
  28. package/agents/eda-review-frontend/prompt.md +34 -0
  29. package/agents/eda-review-performance/agent.json +15 -0
  30. package/agents/eda-review-performance/prompt.md +34 -0
  31. package/agents/eda-review-plan-alignment/agent.json +15 -0
  32. package/agents/eda-review-plan-alignment/prompt.md +34 -0
  33. package/agents/eda-review-previous-reviews/agent.json +15 -0
  34. package/agents/eda-review-previous-reviews/prompt.md +44 -0
  35. package/agents/eda-review-references/agent.json +15 -0
  36. package/agents/eda-review-references/prompt.md +34 -0
  37. package/agents/eda-review-rules/agent.json +15 -0
  38. package/agents/eda-review-rules/prompt.md +34 -0
  39. package/agents/eda-review-security/agent.json +15 -0
  40. package/agents/eda-review-security/prompt.md +34 -0
  41. package/agents/eda-review-tests/agent.json +15 -0
  42. package/agents/eda-review-tests/prompt.md +34 -0
  43. package/agents/eda-worktree-executor/agent.json +15 -0
  44. package/agents/eda-worktree-executor/prompt.md +49 -0
  45. package/bin/cli.js +4 -4
  46. package/lib/catalog.js +118 -0
  47. package/lib/install.js +763 -113
  48. package/lib/renderers/claude-agent.js +24 -0
  49. package/lib/renderers/claude-skill.js +11 -0
  50. package/lib/renderers/codex-agent.js +24 -0
  51. package/lib/renderers/codex-skill.js +3 -0
  52. package/package.json +5 -3
  53. package/skills/eda-aim/SKILL.md +212 -0
  54. package/skills/eda-commit/SKILL.md +124 -0
  55. package/skills/eda-commit/skill.json +6 -0
  56. package/skills/eda-discover-automations/SKILL.md +184 -0
  57. package/skills/{eda-explore.md → eda-explore/SKILL.md} +11 -8
  58. package/skills/{eda-fix.md → eda-fix/SKILL.md} +7 -6
  59. package/skills/{eda-fix-by-review.md → eda-fix-by-review/SKILL.md} +5 -4
  60. package/skills/{eda-manual-test.md → eda-manual-test/SKILL.md} +1 -1
  61. package/skills/eda-merge-worktree/SKILL.md +72 -0
  62. package/skills/eda-merge-worktree/skill.json +7 -0
  63. package/skills/eda-new-project/SKILL.md +163 -0
  64. package/skills/eda-orhestra/SKILL.md +146 -0
  65. package/skills/{eda-plan.md → eda-plan/SKILL.md} +39 -29
  66. package/skills/eda-plan-execute/SKILL.md +186 -0
  67. package/skills/{eda-plan-polish.md → eda-plan-polish/SKILL.md} +14 -13
  68. package/skills/eda-polish/SKILL.md +80 -0
  69. package/skills/eda-prepare-ai/SKILL.md +227 -0
  70. package/skills/eda-review/SKILL.md +212 -0
  71. package/skills/{eda-send-review.md → eda-send-review/SKILL.md} +66 -19
  72. package/skills/eda-send-review/skill.json +7 -0
  73. package/skills/eda-worktree/SKILL.md +73 -0
  74. package/skills/eda-worktree/skill.json +7 -0
  75. package/skills/eda-automate.md +0 -185
  76. package/skills/eda-commit.md +0 -91
  77. package/skills/eda-docs.md +0 -123
  78. package/skills/eda-execute.md +0 -140
  79. package/skills/eda-merge-worktree.md +0 -116
  80. package/skills/eda-polish.md +0 -113
  81. package/skills/eda-review-check.md +0 -149
  82. package/skills/eda-review.md +0 -173
  83. package/skills/eda-start.md +0 -233
  84. package/skills/eda-worktree.md +0 -113
  85. /package/skills/{eda-roadmap.md → eda-roadmap/SKILL.md} +0 -0
package/lib/install.js CHANGED
@@ -4,10 +4,18 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
+ import { listAgents, listSkills } from './catalog.js';
8
+ import { renderClaudeAgent } from './renderers/claude-agent.js';
9
+ import { renderClaudeSkill } from './renderers/claude-skill.js';
10
+ import { renderCodexAgent } from './renderers/codex-agent.js';
11
+ import { renderCodexSkill } from './renderers/codex-skill.js';
12
+
7
13
  const __filename = fileURLToPath(import.meta.url);
8
14
  const __dirname = path.dirname(__filename);
9
15
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
10
16
  const SKILLS_SRC = path.join(PACKAGE_ROOT, 'skills');
17
+ const AGENTS_SRC = path.join(PACKAGE_ROOT, 'agents');
18
+ const PACKAGE_JSON_PATH = path.join(PACKAGE_ROOT, 'package.json');
11
19
  const SETTINGS_RELATIVE_PATH = 'docs/settings.yaml';
12
20
  const TARGET_CHOICES = [
13
21
  { value: 'claude', label: 'Claude Code', dir: '.claude/skills/' },
@@ -15,33 +23,260 @@ const TARGET_CHOICES = [
15
23
  ];
16
24
  const UPDATE_ALL_MAX_DEPTH = 2;
17
25
  const UPDATE_ALL_SKIP_DIRS = new Set(['.git', '.claude', '.codex', 'node_modules']);
18
- const RETIRED_SKILLS = ['eda-research'];
26
+ const RETIRED_SKILLS = [
27
+ 'eda-research',
28
+ 'eda-review-check',
29
+ 'eda-execute',
30
+ 'eda-automate',
31
+ 'eda-docs',
32
+ 'eda-start'
33
+ ];
34
+ const RETIRED_AGENTS = [];
35
+ const MANIFEST_FILE = 'eda-manifest.json';
36
+ const REVIEW_AGENT_DEFINITIONS = [
37
+ {
38
+ key: 'correctness',
39
+ description: 'Проверяет ошибки в логике, крайние случаи и обработку ошибок.',
40
+ auto: 'Эта базовая проверка по умолчанию запускается всегда.',
41
+ mode: 'always',
42
+ claude: 'sonnet',
43
+ codex: 'gpt-5.6-terra'
44
+ },
45
+ {
46
+ key: 'architecture',
47
+ description: 'Проверяет архитектурные границы, зависимости и разделение ответственности.',
48
+ auto: 'В auto запускается при изменении модулей, зависимостей, публичных контрактов или границ системы.',
49
+ mode: 'auto',
50
+ claude: 'opus',
51
+ codex: 'gpt-5.6-sol'
52
+ },
53
+ {
54
+ key: 'rules',
55
+ description: 'Проверяет соблюдение AGENTS.md, CLAUDE.md и docs/rules.md.',
56
+ auto: 'Эта базовая проверка по умолчанию запускается всегда.',
57
+ mode: 'always',
58
+ claude: 'haiku',
59
+ codex: 'gpt-5.6-luna'
60
+ },
61
+ {
62
+ key: 'references',
63
+ description: 'Проверяет соответствие применимым карточкам из docs/references/.',
64
+ auto: 'В auto запускается только когда для изменений выбраны применимые карточки.',
65
+ mode: 'auto',
66
+ claude: 'haiku',
67
+ codex: 'gpt-5.6-luna'
68
+ },
69
+ {
70
+ key: 'plan_alignment',
71
+ description: 'Проверяет, что реализация соответствует указанному или однозначно найденному плану.',
72
+ auto: 'В auto запускается только когда у ревью есть конкретный план.',
73
+ mode: 'auto',
74
+ claude: 'sonnet',
75
+ codex: 'gpt-5.6-terra'
76
+ },
77
+ {
78
+ key: 'code_quality',
79
+ description: 'Проверяет читаемость, сложность, дублирование и поддерживаемость кода.',
80
+ auto: 'Эта базовая проверка по умолчанию запускается всегда.',
81
+ mode: 'always',
82
+ claude: 'sonnet',
83
+ codex: 'gpt-5.6-terra'
84
+ },
85
+ {
86
+ key: 'tests',
87
+ description: 'Проверяет достаточность, корректность и устойчивость тестов.',
88
+ auto: 'Эта базовая проверка по умолчанию запускается всегда.',
89
+ mode: 'always',
90
+ claude: 'sonnet',
91
+ codex: 'gpt-5.6-terra'
92
+ },
93
+ {
94
+ key: 'security',
95
+ description: 'Проверяет уязвимости, права доступа, валидацию и работу с чувствительными данными.',
96
+ auto: 'В auto запускается для auth, permissions, внешнего ввода, файлов, сети, секретов и криптографии.',
97
+ mode: 'auto',
98
+ claude: 'opus',
99
+ codex: 'gpt-5.6-sol'
100
+ },
101
+ {
102
+ key: 'performance',
103
+ description: 'Проверяет запросы, циклы, память, конкурентность и другие риски производительности.',
104
+ auto: 'В auto запускается при изменении запросов, пакетной обработки, кешей, конкурентности или горячих путей.',
105
+ mode: 'auto',
106
+ claude: 'sonnet',
107
+ codex: 'gpt-5.6-terra'
108
+ },
109
+ {
110
+ key: 'frontend',
111
+ description: 'Проверяет фронтенд-код, UI, UX, адаптивность и состояния интерфейса.',
112
+ auto: 'В auto обязательно запускается при изменении компонентов, стилей, клиентских маршрутов или состояния UI.',
113
+ mode: 'auto',
114
+ claude: 'sonnet',
115
+ codex: 'gpt-5.6-terra'
116
+ },
117
+ {
118
+ key: 'api',
119
+ description: 'Проверяет API-контракты, совместимость, валидацию и обработку ошибок.',
120
+ auto: 'В auto запускается при изменении маршрутов, handlers, DTO, схем API, GraphQL или RPC.',
121
+ mode: 'auto',
122
+ claude: 'sonnet',
123
+ codex: 'gpt-5.6-terra'
124
+ },
125
+ {
126
+ key: 'database',
127
+ description: 'Проверяет схемы, миграции, запросы, индексы и целостность данных.',
128
+ auto: 'В auto запускается при изменении миграций, моделей хранения, SQL, схем или транзакций.',
129
+ mode: 'auto',
130
+ claude: 'opus',
131
+ codex: 'gpt-5.6-sol'
132
+ },
133
+ {
134
+ key: 'documentation',
135
+ description: 'Проверяет, обновлена ли документация для изменившегося пользовательского поведения.',
136
+ auto: 'В auto запускается при изменении публичного API, CLI, настроек или документируемого поведения.',
137
+ mode: 'auto',
138
+ claude: 'haiku',
139
+ codex: 'gpt-5.6-luna'
140
+ },
141
+ {
142
+ key: 'previous_reviews',
143
+ description: 'Перепроверяет незакрытые замечания предыдущих ревью для удалённого PR или MR.',
144
+ auto: 'В auto запускается только для ссылки на PR/MR или другой цели с доступными удалёнными обсуждениями.',
145
+ mode: 'auto',
146
+ claude: 'haiku',
147
+ codex: 'gpt-5.6-luna'
148
+ }
149
+ ];
150
+ const DEFAULT_REVIEW_AGENTS = Object.fromEntries(REVIEW_AGENT_DEFINITIONS.map(agent => [
151
+ agent.key,
152
+ {
153
+ mode: agent.mode,
154
+ model: {
155
+ claude: agent.claude,
156
+ codex: agent.codex
157
+ }
158
+ }
159
+ ]));
160
+ const ORHESTRA_STEP_DEFINITIONS = [
161
+ {
162
+ id: 'plan',
163
+ skill: 'eda-plan',
164
+ name: 'Планирование через eda-plan',
165
+ enabled: true,
166
+ args: ''
167
+ },
168
+ {
169
+ id: 'execute',
170
+ skill: 'eda-plan-execute',
171
+ name: 'Управляемое выполнение плана через eda-plan-execute',
172
+ enabled: true,
173
+ args: ''
174
+ },
175
+ {
176
+ id: 'polish',
177
+ skill: 'eda-polish',
178
+ name: 'Ревью и исправления через eda-polish',
179
+ enabled: true,
180
+ args: 'limit 5'
181
+ },
182
+ {
183
+ id: 'manual-test',
184
+ skill: 'eda-manual-test',
185
+ name: 'Ручная проверка через eda-manual-test',
186
+ enabled: true,
187
+ args: '',
188
+ onFailure: {
189
+ skill: 'eda-fix',
190
+ args: '',
191
+ then: ['polish', 'manual-test'],
192
+ maxCycles: 5
193
+ }
194
+ }
195
+ ];
196
+ const DEFAULT_ORHESTRA_STEPS = ORHESTRA_STEP_DEFINITIONS.map(({ name, ...step }) => step);
19
197
  const DEFAULT_SETTINGS = {
20
- strict: false,
21
- planSize: 'normal',
22
- decisionMode: 'recommend_and_ask',
23
- testStrategy: 'ask_each_time',
24
- loggingStrategy: 'ask_each_time',
25
- includePlans: false,
26
- includeCodeQuality: true
198
+ orhestra: {
199
+ mode: 'automatic',
200
+ steps: DEFAULT_ORHESTRA_STEPS
201
+ },
202
+ aim: {
203
+ mode: 'automatic'
204
+ },
205
+ explore: {
206
+ strict: false,
207
+ decisionMode: 'recommend_and_ask'
208
+ },
209
+ plan: {
210
+ strict: false,
211
+ size: 'normal',
212
+ decisionMode: 'recommend_and_ask',
213
+ testStrategy: 'ask_each_time',
214
+ loggingStrategy: 'ask_each_time'
215
+ },
216
+ planPolish: {
217
+ strict: false
218
+ },
219
+ review: {
220
+ agents: DEFAULT_REVIEW_AGENTS
221
+ },
222
+ sendReview: {
223
+ closePreviousReviews: false
224
+ },
225
+ discoverAutomations: {
226
+ includePlans: false
227
+ }
27
228
  };
28
229
  const SETTINGS_CHOICES = [
29
230
  {
30
- value: 'strict',
31
- name: 'Strict по умолчанию для explore / plan / review / review-check',
32
- checked: DEFAULT_SETTINGS.strict
231
+ value: 'exploreStrict',
232
+ name: 'Strict по умолчанию для eda-explore',
233
+ checked: DEFAULT_SETTINGS.explore.strict
234
+ },
235
+ {
236
+ value: 'planStrict',
237
+ name: 'Strict по умолчанию для eda-plan',
238
+ checked: DEFAULT_SETTINGS.plan.strict
33
239
  },
34
240
  {
35
- value: 'includePlans',
36
- name: 'Анализировать планы в eda-automate по умолчанию',
37
- checked: DEFAULT_SETTINGS.includePlans
241
+ value: 'planPolishStrict',
242
+ name: 'Strict по умолчанию для eda-plan-polish',
243
+ checked: DEFAULT_SETTINGS.planPolish.strict
38
244
  },
39
245
  {
40
- value: 'includeCodeQuality',
41
- name: 'Проверять качество кода в eda-review / eda-review-check',
42
- checked: DEFAULT_SETTINGS.includeCodeQuality
246
+ value: 'sendReviewClosePreviousReviews',
247
+ name: 'Закрывать предыдущие ревью eda-send-review',
248
+ checked: DEFAULT_SETTINGS.sendReview.closePreviousReviews
249
+ },
250
+ {
251
+ value: 'discoverAutomationsIncludePlans',
252
+ name: 'Анализировать планы в eda-discover-automations по умолчанию',
253
+ checked: DEFAULT_SETTINGS.discoverAutomations.includePlans
43
254
  }
44
255
  ];
256
+ const ORHESTRA_MODE_CHOICES = [
257
+ {
258
+ value: 'automatic',
259
+ name: 'Автоматически пройти план, выполнение, ревью и ручные тесты'
260
+ },
261
+ {
262
+ value: 'manual',
263
+ name: 'Писать план вместе с человеком и передавать ему вопросы'
264
+ }
265
+ ];
266
+ const AIM_MODE_CHOICES = [
267
+ {
268
+ value: 'automatic',
269
+ name: 'Автоматически отвечать на безопасные рабочие вопросы'
270
+ },
271
+ {
272
+ value: 'manual',
273
+ name: 'Передавать рабочие вопросы человеку'
274
+ }
275
+ ];
276
+ const ORHESTRA_POLISH_LIMIT_CHOICES = [1, 2, 3, 5, 10].map(value => ({
277
+ value,
278
+ name: `${value} ${value === 1 ? 'итерация' : value < 5 ? 'итерации' : 'итераций'}`
279
+ }));
45
280
  const PLAN_SIZE_CHOICES = [
46
281
  {
47
282
  value: 'normal',
@@ -102,6 +337,21 @@ const LOGGING_STRATEGY_CHOICES = [
102
337
  name: 'Спрашивать стратегию логирования каждый раз'
103
338
  }
104
339
  ];
340
+ const REVIEW_MODE_CHOICES = [
341
+ { value: 'always', name: 'always — запускать при каждом ревью' },
342
+ { value: 'auto', name: 'auto — запускать только когда проверка применима' },
343
+ { value: 'off', name: 'off — отключить проверку' }
344
+ ];
345
+ const CLAUDE_REVIEW_MODEL_CHOICES = [
346
+ { value: 'haiku', name: 'haiku — быстрая проверка' },
347
+ { value: 'sonnet', name: 'sonnet — сильная проверка кода' },
348
+ { value: 'opus', name: 'opus — максимальная глубина' }
349
+ ];
350
+ const CODEX_REVIEW_MODEL_CHOICES = [
351
+ { value: 'gpt-5.6-luna', name: 'gpt-5.6-luna — быстрая проверка' },
352
+ { value: 'gpt-5.6-terra', name: 'gpt-5.6-terra — сильная проверка кода' },
353
+ { value: 'gpt-5.6-sol', name: 'gpt-5.6-sol — максимальная глубина' }
354
+ ];
105
355
 
106
356
  export async function init({ cwd, input = process.stdin, output = process.stdout }) {
107
357
  const targets = await askTargets({ input, output });
@@ -110,18 +360,18 @@ export async function init({ cwd, input = process.stdin, output = process.stdout
110
360
  return;
111
361
  }
112
362
  await ensureSettings(cwd, { input, output });
113
- await syncSkills(cwd, targets, output, { action: 'install' });
363
+ await syncPackage(cwd, targets, output, { action: 'install' });
114
364
  }
115
365
 
116
366
  export async function update({ cwd, input = process.stdin, output = process.stdout }) {
117
367
  const targets = await detectTargets(cwd);
118
368
  if (targets.length === 0) {
119
- output.write('Не нашёл установленных скилов в этом проекте. Запусти `eda init`.\n');
369
+ output.write('Не нашёл установленного пакета eda в этом проекте. Запусти `eda init`.\n');
120
370
  return;
121
371
  }
122
372
  output.write(`Найдены установленные среды: ${targets.join(', ')}\n`);
123
373
  await ensureSettings(cwd, { input, output });
124
- await syncSkills(cwd, targets, output, { action: 'update' });
374
+ await syncPackage(cwd, targets, output, { action: 'update' });
125
375
  }
126
376
 
127
377
  export async function updateAll({
@@ -139,11 +389,11 @@ export async function updateAll({
139
389
  throw new Error(`Директория не найдена: ${rootDir}`);
140
390
  }
141
391
 
142
- output.write(`Ищу проекты с установленными скилами в ${rootDir} (глубина ${maxDepth}).\n`);
392
+ output.write(`Ищу проекты с установленным пакетом eda в ${rootDir} (глубина ${maxDepth}).\n`);
143
393
 
144
394
  const projects = await findInstalledProjects(rootDir, maxDepth);
145
395
  if (projects.length === 0) {
146
- output.write('Не нашёл проектов с установленными скилами.\n');
396
+ output.write('Не нашёл проектов с установленным пакетом eda.\n');
147
397
  return {
148
398
  root: rootDir,
149
399
  maxDepth,
@@ -173,14 +423,15 @@ export async function updateAll({
173
423
  }
174
424
 
175
425
  output.write(`Найдены установленные среды: ${targets.join(', ')}\n`);
176
- const result = await syncSkills(projectDir, targets, output, {
426
+ const result = await syncPackage(projectDir, targets, output, {
177
427
  action: 'update',
178
428
  writeDone: false
179
429
  });
180
430
  updatedProjects.push({
181
431
  path: projectDir,
182
432
  targets,
183
- changedSkills: result.changedSkills
433
+ changedSkills: result.changedSkills,
434
+ changedAgents: result.changedAgents
184
435
  });
185
436
  } catch (err) {
186
437
  failedProjects.push({ path: projectDir, error: err });
@@ -213,7 +464,7 @@ export async function askTargets({ input = process.stdin, output = process.stdou
213
464
  }
214
465
 
215
466
  return checkbox({
216
- message: 'Куда устанавливать скилы?',
467
+ message: 'Куда устанавливать пакет eda?',
217
468
  instructions: 'Стрелки — выбрать, Space — отметить, Enter — продолжить',
218
469
  choices: TARGET_CHOICES.map(choice => ({
219
470
  name: `${choice.label} (${choice.dir})`,
@@ -229,7 +480,7 @@ export async function askTargets({ input = process.stdin, output = process.stdou
229
480
  export async function askSettings({ input = process.stdin, output = process.stdout } = {}) {
230
481
  if (!input.isTTY || !output.isTTY) {
231
482
  output.write(`Нет интерактивного терминала — создаю ${SETTINGS_RELATIVE_PATH} с настройками по умолчанию.\n`);
232
- return { ...DEFAULT_SETTINGS };
483
+ return structuredClone(DEFAULT_SETTINGS);
233
484
  }
234
485
 
235
486
  const selected = await checkbox({
@@ -241,19 +492,76 @@ export async function askSettings({ input = process.stdin, output = process.stdo
241
492
  output
242
493
  });
243
494
 
495
+ const orhestraMode = await select({
496
+ message: 'Как eda-orhestra должен вести полный цикл по умолчанию?',
497
+ choices: ORHESTRA_MODE_CHOICES,
498
+ default: DEFAULT_SETTINGS.orhestra.mode
499
+ }, {
500
+ input,
501
+ output
502
+ });
503
+
504
+ const enabledOrhestraSteps = await checkbox({
505
+ message: 'Какие этапы eda-orhestra включить по умолчанию?',
506
+ instructions: 'Порядок можно изменить вручную в docs/settings.yaml',
507
+ choices: ORHESTRA_STEP_DEFINITIONS.map(step => ({
508
+ name: step.name,
509
+ value: step.skill,
510
+ checked: step.enabled
511
+ }))
512
+ }, {
513
+ input,
514
+ output
515
+ });
516
+
517
+ const polishLimit = enabledOrhestraSteps.includes('eda-polish')
518
+ ? await select({
519
+ message: 'Сколько итераций разрешить eda-polish внутри eda-orhestra?',
520
+ choices: ORHESTRA_POLISH_LIMIT_CHOICES,
521
+ default: 5
522
+ }, {
523
+ input,
524
+ output
525
+ })
526
+ : 5;
527
+
528
+ const orhestraSteps = DEFAULT_ORHESTRA_STEPS.map(step => ({
529
+ ...step,
530
+ enabled: enabledOrhestraSteps.includes(step.skill),
531
+ ...(step.skill === 'eda-polish' ? { args: `limit ${polishLimit}` } : {})
532
+ }));
533
+
534
+ const aimMode = await select({
535
+ message: 'Как eda-aim должен отвечать на рабочие вопросы по умолчанию?',
536
+ choices: AIM_MODE_CHOICES,
537
+ default: DEFAULT_SETTINGS.aim.mode
538
+ }, {
539
+ input,
540
+ output
541
+ });
542
+
543
+ const exploreDecisionMode = await select({
544
+ message: 'Как принимать важные решения в eda-explore?',
545
+ choices: DECISION_MODE_CHOICES,
546
+ default: DEFAULT_SETTINGS.explore.decisionMode
547
+ }, {
548
+ input,
549
+ output
550
+ });
551
+
244
552
  const planSize = await select({
245
553
  message: 'Какой размер плана eda-plan использовать по умолчанию?',
246
554
  choices: PLAN_SIZE_CHOICES,
247
- default: DEFAULT_SETTINGS.planSize
555
+ default: DEFAULT_SETTINGS.plan.size
248
556
  }, {
249
557
  input,
250
558
  output
251
559
  });
252
560
 
253
- const decisionMode = await select({
254
- message: 'Как принимать важные решения в eda-explore и eda-plan?',
561
+ const planDecisionMode = await select({
562
+ message: 'Как принимать важные решения в eda-plan?',
255
563
  choices: DECISION_MODE_CHOICES,
256
- default: DEFAULT_SETTINGS.decisionMode
564
+ default: DEFAULT_SETTINGS.plan.decisionMode
257
565
  }, {
258
566
  input,
259
567
  output
@@ -262,7 +570,7 @@ export async function askSettings({ input = process.stdin, output = process.stdo
262
570
  const testStrategy = await select({
263
571
  message: 'Какую стратегию тестов eda-plan использовать по умолчанию?',
264
572
  choices: TEST_STRATEGY_CHOICES,
265
- default: DEFAULT_SETTINGS.testStrategy
573
+ default: DEFAULT_SETTINGS.plan.testStrategy
266
574
  }, {
267
575
  input,
268
576
  output
@@ -271,40 +579,120 @@ export async function askSettings({ input = process.stdin, output = process.stdo
271
579
  const loggingStrategy = await select({
272
580
  message: 'Какую стратегию логирования eda-plan использовать по умолчанию?',
273
581
  choices: LOGGING_STRATEGY_CHOICES,
274
- default: DEFAULT_SETTINGS.loggingStrategy
582
+ default: DEFAULT_SETTINGS.plan.loggingStrategy
275
583
  }, {
276
584
  input,
277
585
  output
278
586
  });
279
587
 
588
+ const reviewAgents = await askReviewAgentSettings({ input, output });
589
+
280
590
  return {
281
- strict: selected.includes('strict'),
282
- planSize,
283
- decisionMode,
284
- testStrategy,
285
- loggingStrategy,
286
- includePlans: selected.includes('includePlans'),
287
- includeCodeQuality: selected.includes('includeCodeQuality')
591
+ orhestra: {
592
+ mode: orhestraMode,
593
+ steps: orhestraSteps
594
+ },
595
+ aim: {
596
+ mode: aimMode
597
+ },
598
+ explore: {
599
+ strict: selected.includes('exploreStrict'),
600
+ decisionMode: exploreDecisionMode
601
+ },
602
+ plan: {
603
+ strict: selected.includes('planStrict'),
604
+ size: planSize,
605
+ decisionMode: planDecisionMode,
606
+ testStrategy,
607
+ loggingStrategy
608
+ },
609
+ planPolish: {
610
+ strict: selected.includes('planPolishStrict')
611
+ },
612
+ review: {
613
+ agents: reviewAgents
614
+ },
615
+ sendReview: {
616
+ closePreviousReviews: selected.includes('sendReviewClosePreviousReviews')
617
+ },
618
+ discoverAutomations: {
619
+ includePlans: selected.includes('discoverAutomationsIncludePlans')
620
+ }
288
621
  };
289
622
  }
290
623
 
624
+ export async function askReviewAgentSettings({
625
+ input = process.stdin,
626
+ output = process.stdout,
627
+ selectPrompt = select
628
+ } = {}) {
629
+ const reviewAgents = {};
630
+ for (const agent of REVIEW_AGENT_DEFINITIONS) {
631
+ const mode = await selectPrompt({
632
+ message: `Когда запускать review-проверку ${agent.key}?`,
633
+ choices: REVIEW_MODE_CHOICES,
634
+ default: agent.mode
635
+ }, {
636
+ input,
637
+ output
638
+ });
639
+
640
+ let claude = agent.claude;
641
+ let codex = agent.codex;
642
+ if (mode !== 'off') {
643
+ claude = await selectPrompt({
644
+ message: `Какой моделью Claude проверять ${agent.key}?`,
645
+ choices: CLAUDE_REVIEW_MODEL_CHOICES,
646
+ default: agent.claude
647
+ }, {
648
+ input,
649
+ output
650
+ });
651
+ codex = await selectPrompt({
652
+ message: `Какой моделью Codex проверять ${agent.key}?`,
653
+ choices: CODEX_REVIEW_MODEL_CHOICES,
654
+ default: agent.codex
655
+ }, {
656
+ input,
657
+ output
658
+ });
659
+ }
660
+ reviewAgents[agent.key] = { mode, model: { claude, codex } };
661
+ }
662
+ return reviewAgents;
663
+ }
664
+
291
665
  async function detectTargets(cwd) {
292
666
  const targets = [];
293
- if (await dirExists(path.join(cwd, '.claude/skills'))) targets.push('claude');
294
- if (await dirExists(path.join(cwd, '.codex/skills'))) targets.push('codex');
667
+ if (await targetStructureExists(cwd, 'claude')) targets.push('claude');
668
+ if (await targetStructureExists(cwd, 'codex')) targets.push('codex');
295
669
  return targets;
296
670
  }
297
671
 
298
- // Среда считается установленной, только если в её папке скилов лежит хотя бы один eda-скил.
299
- // Используется при массовом обходе (update-all), чтобы не засыпать скилами проекты,
300
- // где папка .claude/skills или .codex/skills есть, но eda-скилов в ней нет.
672
+ // При массовом обходе среда считается установленной по eda-скилу, eda-агенту
673
+ // или manifest владения. Одних пустых/чужих каталогов skills и agents недостаточно.
301
674
  async function detectInstalledTargets(cwd) {
302
675
  const targets = [];
303
- if (await hasInstalledSkill(path.join(cwd, '.claude/skills'))) targets.push('claude');
304
- if (await hasInstalledSkill(path.join(cwd, '.codex/skills'))) targets.push('codex');
676
+ if (await hasInstalledPackage(cwd, 'claude')) targets.push('claude');
677
+ if (await hasInstalledPackage(cwd, 'codex')) targets.push('codex');
305
678
  return targets;
306
679
  }
307
680
 
681
+ async function targetStructureExists(cwd, target) {
682
+ const root = path.join(cwd, `.${target}`);
683
+ return await dirExists(path.join(root, 'skills'))
684
+ || await dirExists(path.join(root, 'agents'))
685
+ || await fileExists(path.join(root, MANIFEST_FILE));
686
+ }
687
+
688
+ async function hasInstalledPackage(cwd, target) {
689
+ const root = path.join(cwd, `.${target}`);
690
+ const agentExtension = target === 'claude' ? '.md' : '.toml';
691
+ return await fileExists(path.join(root, MANIFEST_FILE))
692
+ || await hasInstalledSkill(path.join(root, 'skills'))
693
+ || await hasInstalledAgent(path.join(root, 'agents'), agentExtension);
694
+ }
695
+
308
696
  async function hasInstalledSkill(skillsDir) {
309
697
  let entries;
310
698
  try {
@@ -317,31 +705,53 @@ async function hasInstalledSkill(skillsDir) {
317
705
  return entries.some(entry => entry.name.startsWith('eda-'));
318
706
  }
319
707
 
320
- async function syncSkills(cwd, targets, output = process.stdout, { action = 'update', writeDone = true } = {}) {
321
- const skills = await listSkills();
708
+ async function hasInstalledAgent(agentsDir, extension) {
709
+ let entries;
710
+ try {
711
+ entries = await fs.readdir(agentsDir, { withFileTypes: true });
712
+ } catch (err) {
713
+ if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(err?.code)) return false;
714
+ throw err;
715
+ }
716
+ return entries.some(entry => entry.isFile() && entry.name.startsWith('eda-') && entry.name.endsWith(extension));
717
+ }
718
+
719
+ async function syncPackage(cwd, targets, output = process.stdout, { action = 'update', writeDone = true } = {}) {
720
+ const skills = await listSkills(SKILLS_SRC);
322
721
  if (skills.length === 0) {
323
722
  throw new Error(`В пакете нет скилов (искал в ${SKILLS_SRC}).`);
324
723
  }
724
+ const agents = await listAgents(AGENTS_SRC);
725
+ const packageVersion = JSON.parse(await fs.readFile(PACKAGE_JSON_PATH, 'utf8')).version;
325
726
  output.write(`Скилы для установки: ${skills.map(s => s.name).join(', ')}\n`);
727
+ if (agents.length > 0) {
728
+ output.write(`Агенты для установки: ${agents.map(agent => agent.name).join(', ')}\n`);
729
+ }
326
730
 
327
731
  const changedSkills = new Set();
732
+ const changedAgents = new Set();
328
733
  if (targets.includes('claude')) {
329
- const result = await installToClaude(cwd, skills, output);
734
+ const result = await installTarget(cwd, 'claude', skills, agents, packageVersion, output);
330
735
  for (const skillName of result.changedSkills) changedSkills.add(skillName);
736
+ for (const agentName of result.changedAgents) changedAgents.add(agentName);
331
737
  }
332
738
  if (targets.includes('codex')) {
333
- const result = await installToCodex(cwd, skills, output);
739
+ const result = await installTarget(cwd, 'codex', skills, agents, packageVersion, output);
334
740
  for (const skillName of result.changedSkills) changedSkills.add(skillName);
741
+ for (const agentName of result.changedAgents) changedAgents.add(agentName);
335
742
  }
336
- await removeRetiredSkills(cwd, targets);
337
743
 
338
744
  const actionLabel = action === 'install' ? 'Установлено' : 'Обновлено';
339
745
  const changedSkillNames = skills
340
746
  .map(skill => skill.name)
341
747
  .filter(skillName => changedSkills.has(skillName));
748
+ const changedAgentNames = agents
749
+ .map(agent => agent.name)
750
+ .filter(agentName => changedAgents.has(agentName));
342
751
  output.write(formatChangedSkills(actionLabel, changedSkillNames));
752
+ output.write(formatChangedAgents(actionLabel, changedAgentNames));
343
753
  if (writeDone) output.write('\nГотово.\n');
344
- return { changedSkills: changedSkillNames };
754
+ return { changedSkills: changedSkillNames, changedAgents: changedAgentNames };
345
755
  }
346
756
 
347
757
  async function findInstalledProjects(rootDir, maxDepth) {
@@ -373,14 +783,6 @@ async function findInstalledProjects(rootDir, maxDepth) {
373
783
  return projects;
374
784
  }
375
785
 
376
- async function listSkills() {
377
- const entries = await fs.readdir(SKILLS_SRC);
378
- return entries
379
- .filter(f => f.endsWith('.md'))
380
- .map(f => ({ name: f.replace(/\.md$/, ''), path: path.join(SKILLS_SRC, f) }))
381
- .sort((a, b) => a.name.localeCompare(b.name));
382
- }
383
-
384
786
  async function ensureSettings(cwd, { input = process.stdin, output = process.stdout } = {}) {
385
787
  const settingsPath = path.join(cwd, SETTINGS_RELATIVE_PATH);
386
788
  if (await fileExists(settingsPath)) {
@@ -397,72 +799,253 @@ async function ensureSettings(cwd, { input = process.stdin, output = process.std
397
799
  }
398
800
 
399
801
  function formatSettings(settings) {
400
- return `version: 1
802
+ const orhestra = settings.orhestra ?? DEFAULT_SETTINGS.orhestra;
803
+ const aim = settings.aim ?? DEFAULT_SETTINGS.aim;
804
+ const orhestraSteps = (orhestra.steps ?? DEFAULT_ORHESTRA_STEPS).map(step => {
805
+ const onFailureThen = step.onFailure?.then
806
+ ?.map(stepId => ` - ${stepId}`)
807
+ .join('\n');
808
+ const onFailure = step.onFailure
809
+ ? `
810
+ # Обработка failed-результата этого шага.
811
+ on_failure:
812
+ skill: ${step.onFailure.skill}
813
+ args: ${JSON.stringify(step.onFailure.args ?? '')}
814
+ # После исправления повторно запускаются включённые шаги с этими id.
815
+ then:
816
+ ${onFailureThen}
817
+ max_cycles: ${step.onFailure.maxCycles ?? 5}`
818
+ : '';
819
+ return ` - id: ${step.id}
820
+ skill: ${step.skill}
821
+ enabled: ${step.enabled !== false ? 'true' : 'false'}
822
+ # Строка аргументов передаётся скиллу как часть текущего вызова.
823
+ args: ${JSON.stringify(step.args ?? '')}${onFailure}`;
824
+ }).join('\n');
825
+ const reviewAgents = REVIEW_AGENT_DEFINITIONS.map(agent => {
826
+ const configured = settings.review.agents[agent.key] ?? DEFAULT_REVIEW_AGENTS[agent.key];
827
+ return ` # ${agent.description}
828
+ # ${agent.auto}
829
+ ${agent.key}:
830
+ # always — всегда, auto — когда применимо, off — отключено.
831
+ mode: ${configured.mode}
832
+ model:
833
+ # Модель для Claude Code.
834
+ claude: ${configured.model.claude}
835
+ # Модель для Codex.
836
+ codex: ${configured.model.codex}`;
837
+ }).join('\n\n');
838
+
839
+ return `version: 2
840
+
841
+ orhestra:
842
+ # Режим полного цикла eda-orhestra.
843
+ # automatic | manual
844
+ mode: ${orhestra.mode ?? 'automatic'}
845
+ # Упорядоченная цепочка. Шаги можно отключать, удалять и переставлять.
846
+ steps:
847
+ ${orhestraSteps}
848
+
849
+ aim:
850
+ # Режим ответов на рабочие вопросы eda-aim.
851
+ # automatic | manual
852
+ mode: ${aim.mode ?? 'automatic'}
853
+
854
+ explore:
855
+ # Включает кросс-CLI ревью в eda-explore.
856
+ # true | false
857
+ strict: ${settings.explore.strict ? 'true' : 'false'}
858
+ # Определяет, как eda-explore ведёт исследовательские развилки.
859
+ # autonomous | recommend_and_ask | ask_each_time
860
+ decision_mode: ${settings.explore.decisionMode}
401
861
 
402
- defaults:
403
- # Включает strict-режим по умолчанию для eda-explore, eda-plan, eda-plan-polish, eda-review и eda-review-check.
862
+ plan:
863
+ # Включает кросс-CLI ревью в eda-plan.
404
864
  # true | false
405
- strict: ${settings.strict ? 'true' : 'false'}
406
- # Задаёт размер плана по умолчанию для eda-plan.
865
+ strict: ${settings.plan.strict ? 'true' : 'false'}
866
+ # Задаёт размер плана.
407
867
  # normal | short | ask_each_time
408
- plan_size: ${settings.planSize}
409
- # Определяет, как eda-explore ведёт исследовательские развилки, а eda-plan принимает существенные решения.
868
+ size: ${settings.plan.size}
869
+ # Определяет, как eda-plan принимает существенные решения.
410
870
  # autonomous | recommend_and_ask | ask_each_time
411
- decision_mode: ${settings.decisionMode}
412
- # Задаёт стратегию тестов по умолчанию для eda-plan.
871
+ decision_mode: ${settings.plan.decisionMode}
872
+ # Задаёт стратегию тестов.
413
873
  # after_each_phase | tdd_each_phase | end_of_plan | ask_each_time
414
- test_strategy: ${settings.testStrategy}
415
- # Задаёт стратегию логирования по умолчанию для eda-plan.
874
+ test_strategy: ${settings.plan.testStrategy}
875
+ # Задаёт стратегию логирования.
416
876
  # debug_precise | standard | ask_each_time
417
- logging_strategy: ${settings.loggingStrategy}
877
+ logging_strategy: ${settings.plan.loggingStrategy}
418
878
 
419
- automate:
420
- # Добавляет docs/plans/ в обычный запуск eda-automate.
879
+ plan-polish:
880
+ # Включает кросс-CLI ревью в eda-plan-polish.
421
881
  # true | false
422
- include_plans: ${settings.includePlans ? 'true' : 'false'}
882
+ strict: ${settings.planPolish.strict ? 'true' : 'false'}
423
883
 
424
884
  review:
425
- # Добавляет в eda-review / eda-review-check проверку качества кода и meta-reviewer quality-check.
885
+ # Каждая проверка имеет собственный режим запуска и модели для обеих сред.
886
+ agents:
887
+ ${reviewAgents}
888
+
889
+ send-review:
890
+ # После успешной отправки скрывает предыдущие сводки eda-send-review и резолвит их inline-треды.
891
+ # true | false
892
+ close_previous_reviews: ${settings.sendReview.closePreviousReviews ? 'true' : 'false'}
893
+
894
+ discover-automations:
895
+ # Добавляет docs/plans/ в обычный запуск eda-discover-automations.
426
896
  # true | false
427
- include_code_quality: ${settings.includeCodeQuality ? 'true' : 'false'}
897
+ include_plans: ${settings.discoverAutomations.includePlans ? 'true' : 'false'}
428
898
  `;
429
899
  }
430
900
 
431
- async function installToClaude(cwd, skills, output = process.stdout) {
432
- const dst = path.join(cwd, '.claude/skills');
433
- await fs.mkdir(dst, { recursive: true });
901
+ async function installTarget(cwd, target, skills, agents, packageVersion, output = process.stdout) {
902
+ const targetRoot = path.join(cwd, `.${target}`);
903
+ const skillsDir = path.join(targetRoot, 'skills');
904
+ const agentsDir = path.join(targetRoot, 'agents');
905
+ const manifestPath = path.join(targetRoot, MANIFEST_FILE);
906
+ await assertManagedDirectory(targetRoot);
907
+ await assertManagedDirectory(skillsDir);
908
+ await assertManagedDirectory(agentsDir);
909
+ const previousManifest = await readManifest(manifestPath);
434
910
  const changedSkills = [];
911
+ const changedAgents = [];
912
+
913
+ await fs.mkdir(skillsDir, { recursive: true });
435
914
  for (const skill of skills) {
436
- const skillDir = path.join(dst, skill.name);
437
- await fs.mkdir(skillDir, { recursive: true });
438
- const content = await fs.readFile(skill.path, 'utf-8');
439
- const changed = await writeSkillFile(path.join(skillDir, 'SKILL.md'), content);
915
+ const desiredFiles = await buildSkillFiles(skill, target);
916
+ const changed = await syncManagedDirectory(path.join(skillsDir, skill.name), desiredFiles);
440
917
  if (changed) changedSkills.push(skill.name);
918
+ if (target === 'codex') await removeObsoleteCodexFile(skillsDir, skill.name);
919
+ }
920
+
921
+ if (agents.length > 0) await fs.mkdir(agentsDir, { recursive: true });
922
+ for (const agent of agents) {
923
+ const extension = target === 'claude' ? '.md' : '.toml';
924
+ const rendered = target === 'claude'
925
+ ? renderClaudeAgent(agent, agent.prompt)
926
+ : renderCodexAgent(agent, agent.prompt);
927
+ const changed = await writeManagedFile(path.join(agentsDir, `${agent.name}${extension}`), rendered);
928
+ if (changed) changedAgents.push(agent.name);
441
929
  }
442
- output.write(` ✓ Claude Code: ${dst} (изменилось ${formatSkillCount(changedSkills.length)})\n`);
443
- return { changedSkills };
930
+
931
+ await removeRetiredComponents(targetRoot, target, previousManifest, skills, agents);
932
+ await writeManifest(manifestPath, {
933
+ schemaVersion: 1,
934
+ packageVersion,
935
+ skills: skills.map(skill => skill.name),
936
+ agents: agents.map(agent => agent.name)
937
+ });
938
+
939
+ const label = target === 'claude' ? 'Claude Code' : 'Codex CLI';
940
+ output.write(` ✓ ${label}: ${targetRoot} (скилы: ${formatSkillCount(changedSkills.length)}, агенты: ${formatAgentCount(changedAgents.length)})\n`);
941
+ return { changedSkills, changedAgents };
444
942
  }
445
943
 
446
- async function installToCodex(cwd, skills, output = process.stdout) {
447
- const dst = path.join(cwd, '.codex/skills');
448
- await fs.mkdir(dst, { recursive: true });
449
- const changedSkills = [];
450
- for (const skill of skills) {
451
- const skillDir = path.join(dst, skill.name);
452
- await fs.mkdir(skillDir, { recursive: true });
453
- const content = await fs.readFile(skill.path, 'utf-8');
454
- const changed = await writeSkillFile(path.join(skillDir, 'SKILL.md'), content);
455
- if (changed) changedSkills.push(skill.name);
456
- await removeObsoleteCodexFile(dst, skill.name);
944
+ async function buildSkillFiles(skill, target) {
945
+ const files = await readSourceDirectory(skill.sourceDir, new Set(['skill.json']));
946
+ const entrypoint = files.get('SKILL.md');
947
+ if (!entrypoint) throw new Error(`У скила ${skill.name} нет SKILL.md.`);
948
+
949
+ const content = entrypoint.toString('utf8');
950
+ const rendered = target === 'claude'
951
+ ? renderClaudeSkill(content, skill.config)
952
+ : renderCodexSkill(content, skill.config);
953
+ files.set('SKILL.md', Buffer.from(rendered));
954
+ return files;
955
+ }
956
+
957
+ async function syncManagedDirectory(directoryPath, desiredFiles) {
958
+ const currentFiles = await readInstalledDirectory(directoryPath);
959
+ if (fileMapsEqual(currentFiles, desiredFiles)) return false;
960
+
961
+ await fs.rm(directoryPath, { recursive: true, force: true });
962
+ for (const [relativePath, content] of desiredFiles) {
963
+ const destination = path.join(directoryPath, relativePath);
964
+ await fs.mkdir(path.dirname(destination), { recursive: true });
965
+ await fs.writeFile(destination, content);
966
+ }
967
+ return true;
968
+ }
969
+
970
+ async function readSourceDirectory(root, excludedFiles = new Set()) {
971
+ const files = new Map();
972
+
973
+ async function walk(directory, relativeDirectory = '') {
974
+ const entries = await fs.readdir(directory, { withFileTypes: true });
975
+ entries.sort((a, b) => a.name.localeCompare(b.name));
976
+ for (const entry of entries) {
977
+ const relativePath = path.join(relativeDirectory, entry.name);
978
+ if (relativeDirectory === '' && excludedFiles.has(entry.name)) continue;
979
+ if (entry.isSymbolicLink()) {
980
+ throw new Error(`Symlink не поддерживается в исходниках пакета: ${path.join(root, relativePath)}`);
981
+ }
982
+ if (entry.isDirectory()) {
983
+ await walk(path.join(directory, entry.name), relativePath);
984
+ } else if (entry.isFile()) {
985
+ files.set(relativePath, await fs.readFile(path.join(directory, entry.name)));
986
+ }
987
+ }
457
988
  }
458
- output.write(` ✓ Codex CLI: ${dst} (изменилось ${formatSkillCount(changedSkills.length)})\n`);
459
- return { changedSkills };
989
+
990
+ await walk(root);
991
+ return files;
460
992
  }
461
993
 
462
- async function writeSkillFile(filePath, content) {
463
- const previousContent = await readFileIfExists(filePath);
994
+ async function readInstalledDirectory(root) {
995
+ const files = new Map();
996
+ let rootStat;
997
+ try {
998
+ rootStat = await fs.lstat(root);
999
+ } catch (err) {
1000
+ if (err?.code === 'ENOENT') return files;
1001
+ throw err;
1002
+ }
1003
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
1004
+ files.set('', Buffer.from('__not_a_directory__'));
1005
+ return files;
1006
+ }
1007
+
1008
+ async function walk(directory, relativeDirectory = '') {
1009
+ const entries = await fs.readdir(directory, { withFileTypes: true });
1010
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1011
+ for (const entry of entries) {
1012
+ const relativePath = path.join(relativeDirectory, entry.name);
1013
+ if (entry.isSymbolicLink()) {
1014
+ files.set(relativePath, Buffer.from('__symlink__'));
1015
+ } else if (entry.isDirectory()) {
1016
+ await walk(path.join(directory, entry.name), relativePath);
1017
+ } else if (entry.isFile()) {
1018
+ files.set(relativePath, await fs.readFile(path.join(directory, entry.name)));
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ await walk(root);
1024
+ return files;
1025
+ }
1026
+
1027
+ function fileMapsEqual(left, right) {
1028
+ if (left.size !== right.size) return false;
1029
+ for (const [filePath, content] of right) {
1030
+ if (!left.get(filePath)?.equals(content)) return false;
1031
+ }
1032
+ return true;
1033
+ }
1034
+
1035
+ async function writeManagedFile(filePath, content) {
1036
+ const currentStat = await lstatIfExists(filePath);
1037
+ let previousContent = null;
1038
+
1039
+ if (currentStat?.isFile() && !currentStat.isSymbolicLink()) {
1040
+ previousContent = await fs.readFile(filePath, 'utf8');
1041
+ } else if (currentStat) {
1042
+ await fs.rm(filePath, { recursive: true, force: true });
1043
+ }
1044
+
1045
+ if (previousContent === content) return false;
1046
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
464
1047
  await fs.writeFile(filePath, content);
465
- return previousContent !== content;
1048
+ return true;
466
1049
  }
467
1050
 
468
1051
  async function readFileIfExists(filePath) {
@@ -479,10 +1062,19 @@ function formatChangedSkills(actionLabel, skillNames) {
479
1062
  return `${actionLabel} ${formatSkillCount(skillNames.length)}: ${skillNames.join(', ')}.\n`;
480
1063
  }
481
1064
 
1065
+ function formatChangedAgents(actionLabel, agentNames) {
1066
+ if (agentNames.length === 0) return `${actionLabel} 0 агентов.\n`;
1067
+ return `${actionLabel} ${formatAgentCount(agentNames.length)}: ${agentNames.join(', ')}.\n`;
1068
+ }
1069
+
482
1070
  function formatSkillCount(count) {
483
1071
  return `${count} ${pluralizeSkill(count)}`;
484
1072
  }
485
1073
 
1074
+ function formatAgentCount(count) {
1075
+ return `${count} ${pluralizeAgent(count)}`;
1076
+ }
1077
+
486
1078
  function formatProjectPath(rootDir, projectDir) {
487
1079
  const relative = path.relative(rootDir, projectDir);
488
1080
  return relative === '' ? '.' : relative;
@@ -520,6 +1112,14 @@ function pluralizeSkill(count) {
520
1112
  return 'скилов';
521
1113
  }
522
1114
 
1115
+ function pluralizeAgent(count) {
1116
+ const mod10 = count % 10;
1117
+ const mod100 = count % 100;
1118
+ if (mod10 === 1 && mod100 !== 11) return 'агент';
1119
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'агента';
1120
+ return 'агентов';
1121
+ }
1122
+
523
1123
  async function removeObsoleteCodexFile(dst, skillName) {
524
1124
  try {
525
1125
  await fs.rm(path.join(dst, `${skillName}.md`));
@@ -528,23 +1128,53 @@ async function removeObsoleteCodexFile(dst, skillName) {
528
1128
  }
529
1129
  }
530
1130
 
531
- async function removeRetiredSkills(cwd, targets) {
532
- const targetDirs = {
533
- claude: path.join(cwd, '.claude/skills'),
534
- codex: path.join(cwd, '.codex/skills')
535
- };
1131
+ async function removeRetiredComponents(targetRoot, target, previousManifest, skills, agents) {
1132
+ const currentSkills = new Set(skills.map(skill => skill.name));
1133
+ const currentAgents = new Set(agents.map(agent => agent.name));
1134
+ const staleSkills = new Set([
1135
+ ...RETIRED_SKILLS,
1136
+ ...(previousManifest?.skills ?? []).filter(name => !currentSkills.has(name))
1137
+ ].filter(isManagedComponentName));
1138
+ const staleAgents = new Set([
1139
+ ...RETIRED_AGENTS,
1140
+ ...(previousManifest?.agents ?? []).filter(name => !currentAgents.has(name))
1141
+ ].filter(isManagedComponentName));
1142
+
1143
+ for (const skillName of staleSkills) {
1144
+ await fs.rm(path.join(targetRoot, 'skills', skillName), { recursive: true, force: true });
1145
+ await fs.rm(path.join(targetRoot, 'skills', `${skillName}.md`), { force: true });
1146
+ }
536
1147
 
537
- for (const target of targets) {
538
- const dst = targetDirs[target];
539
- if (!dst) continue;
1148
+ const extension = target === 'claude' ? '.md' : '.toml';
1149
+ for (const agentName of staleAgents) {
1150
+ await fs.rm(path.join(targetRoot, 'agents', `${agentName}${extension}`), { force: true });
1151
+ }
1152
+ }
540
1153
 
541
- for (const skillName of RETIRED_SKILLS) {
542
- await fs.rm(path.join(dst, skillName), { recursive: true, force: true });
543
- await fs.rm(path.join(dst, `${skillName}.md`), { force: true });
1154
+ async function readManifest(manifestPath) {
1155
+ try {
1156
+ const manifestStat = await fs.lstat(manifestPath);
1157
+ if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) return null;
1158
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
1159
+ if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.skills) || !Array.isArray(manifest.agents)) {
1160
+ return null;
544
1161
  }
1162
+ return manifest;
1163
+ } catch (err) {
1164
+ if (err?.code === 'ENOENT' || err instanceof SyntaxError) return null;
1165
+ throw err;
545
1166
  }
546
1167
  }
547
1168
 
1169
+ async function writeManifest(manifestPath, manifest) {
1170
+ const content = `${JSON.stringify(manifest, null, 2)}\n`;
1171
+ await writeManagedFile(manifestPath, content);
1172
+ }
1173
+
1174
+ function isManagedComponentName(name) {
1175
+ return typeof name === 'string' && /^eda-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
1176
+ }
1177
+
548
1178
  async function dirExists(p) {
549
1179
  try {
550
1180
  const st = await fs.stat(p);
@@ -571,3 +1201,23 @@ async function statIfExists(p) {
571
1201
  return null;
572
1202
  }
573
1203
  }
1204
+
1205
+ async function lstatIfExists(p) {
1206
+ try {
1207
+ return await fs.lstat(p);
1208
+ } catch (err) {
1209
+ if (err?.code !== 'ENOENT') throw err;
1210
+ return null;
1211
+ }
1212
+ }
1213
+
1214
+ async function assertManagedDirectory(directoryPath) {
1215
+ const directoryStat = await lstatIfExists(directoryPath);
1216
+ if (!directoryStat) return;
1217
+ if (directoryStat.isSymbolicLink()) {
1218
+ throw new Error(`Управляемый каталог не должен быть symlink: ${directoryPath}`);
1219
+ }
1220
+ if (!directoryStat.isDirectory()) {
1221
+ throw new Error(`Ожидался каталог: ${directoryPath}`);
1222
+ }
1223
+ }