@gian-tiaga/eda 0.8.6 → 1.0.1

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