aios-core 3.4.0 → 3.5.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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Squad Migrator Utility
3
+ *
4
+ * Migrates legacy squad formats to AIOS 2.1 standard.
5
+ * Handles manifest, structure, and task format migrations.
6
+ *
7
+ * Used by: squad-creator agent (*migrate-squad task)
8
+ *
9
+ * @module squad-migrator
10
+ * @version 1.0.0
11
+ * @see Story SQS-7: Squad Migration Tool
12
+ */
13
+
14
+ const fs = require('fs').promises;
15
+ const path = require('path');
16
+ const yaml = require('js-yaml');
17
+
18
+ /**
19
+ * Error codes for SquadMigratorError
20
+ * @enum {string}
21
+ */
22
+ const MigratorErrorCodes = {
23
+ SQUAD_NOT_FOUND: 'SQUAD_NOT_FOUND',
24
+ NO_MANIFEST: 'NO_MANIFEST',
25
+ BACKUP_FAILED: 'BACKUP_FAILED',
26
+ MIGRATION_FAILED: 'MIGRATION_FAILED',
27
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
28
+ INVALID_PATH: 'INVALID_PATH',
29
+ };
30
+
31
+ /**
32
+ * Custom error class for migration errors
33
+ */
34
+ class SquadMigratorError extends Error {
35
+ /**
36
+ * @param {string} code - Error code from MigratorErrorCodes
37
+ * @param {string} message - Human-readable error message
38
+ * @param {Object} [details={}] - Additional error details
39
+ */
40
+ constructor(code, message, details = {}) {
41
+ super(message);
42
+ this.name = 'SquadMigratorError';
43
+ this.code = code;
44
+ this.details = details;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Migration analysis result structure
50
+ * @typedef {Object} MigrationAnalysis
51
+ * @property {boolean} needsMigration - Whether migration is required
52
+ * @property {Array<MigrationIssue>} issues - Issues found during analysis
53
+ * @property {Array<MigrationAction>} actions - Actions to perform
54
+ * @property {string} squadPath - Analyzed squad path
55
+ */
56
+
57
+ /**
58
+ * Migration issue structure
59
+ * @typedef {Object} MigrationIssue
60
+ * @property {string} type - Issue type
61
+ * @property {string} message - Human-readable message
62
+ * @property {string} severity - 'error' | 'warning' | 'info'
63
+ */
64
+
65
+ /**
66
+ * Migration action structure
67
+ * @typedef {Object} MigrationAction
68
+ * @property {string} type - Action type
69
+ * @property {Object} [params] - Action parameters
70
+ */
71
+
72
+ /**
73
+ * Migration result structure
74
+ * @typedef {Object} MigrationResult
75
+ * @property {boolean} success - Whether migration succeeded
76
+ * @property {string} message - Result message
77
+ * @property {Array<ExecutedAction>} actions - Actions executed
78
+ * @property {Object|null} validation - Post-migration validation result
79
+ * @property {string|null} backupPath - Path to backup directory
80
+ */
81
+
82
+ /**
83
+ * Squad Migrator class for migrating legacy squad formats
84
+ */
85
+ class SquadMigrator {
86
+ /**
87
+ * Create a SquadMigrator instance
88
+ * @param {Object} [options={}] - Configuration options
89
+ * @param {boolean} [options.dryRun=false] - Simulate without modifying files
90
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
91
+ * @param {Object} [options.validator=null] - Custom SquadValidator instance
92
+ */
93
+ constructor(options = {}) {
94
+ this.dryRun = options.dryRun || false;
95
+ this.verbose = options.verbose || false;
96
+ this.validator = options.validator || null;
97
+ }
98
+
99
+ /**
100
+ * Log message if verbose mode is enabled
101
+ * @param {string} message - Message to log
102
+ * @private
103
+ */
104
+ _log(message) {
105
+ if (this.verbose) {
106
+ console.log(`[SquadMigrator] ${message}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Check if a path exists
112
+ * @param {string} filePath - Path to check
113
+ * @returns {Promise<boolean>}
114
+ * @private
115
+ */
116
+ async _pathExists(filePath) {
117
+ try {
118
+ await fs.access(filePath);
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Recursively copy a file or directory
127
+ * @param {string} src - Source path
128
+ * @param {string} dest - Destination path
129
+ * @private
130
+ */
131
+ async _copyRecursive(src, dest) {
132
+ const stats = await fs.stat(src);
133
+ if (stats.isDirectory()) {
134
+ await fs.mkdir(dest, { recursive: true });
135
+ const entries = await fs.readdir(src);
136
+ for (const entry of entries) {
137
+ await this._copyRecursive(path.join(src, entry), path.join(dest, entry));
138
+ }
139
+ } else {
140
+ await fs.copyFile(src, dest);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Analyze squad for migration needs
146
+ * @param {string} squadPath - Path to squad directory
147
+ * @returns {Promise<MigrationAnalysis>}
148
+ */
149
+ async analyze(squadPath) {
150
+ this._log(`Analyzing squad at: ${squadPath}`);
151
+
152
+ // Verify squad directory exists
153
+ if (!(await this._pathExists(squadPath))) {
154
+ throw new SquadMigratorError(
155
+ MigratorErrorCodes.SQUAD_NOT_FOUND,
156
+ `Squad directory not found: ${squadPath}`,
157
+ { squadPath }
158
+ );
159
+ }
160
+
161
+ const analysis = {
162
+ needsMigration: false,
163
+ issues: [],
164
+ actions: [],
165
+ squadPath,
166
+ };
167
+
168
+ // Check for legacy manifest (config.yaml)
169
+ const hasConfigYaml = await this._pathExists(path.join(squadPath, 'config.yaml'));
170
+ const hasSquadYaml = await this._pathExists(path.join(squadPath, 'squad.yaml'));
171
+
172
+ if (!hasConfigYaml && !hasSquadYaml) {
173
+ throw new SquadMigratorError(
174
+ MigratorErrorCodes.NO_MANIFEST,
175
+ 'No manifest found (config.yaml or squad.yaml)',
176
+ { squadPath }
177
+ );
178
+ }
179
+
180
+ // Check for legacy manifest name
181
+ if (hasConfigYaml && !hasSquadYaml) {
182
+ analysis.needsMigration = true;
183
+ analysis.issues.push({
184
+ type: 'LEGACY_MANIFEST',
185
+ message: 'Uses deprecated config.yaml manifest',
186
+ severity: 'warning',
187
+ });
188
+ analysis.actions.push({
189
+ type: 'RENAME_MANIFEST',
190
+ from: 'config.yaml',
191
+ to: 'squad.yaml',
192
+ });
193
+ }
194
+
195
+ // Check for flat structure (missing directories)
196
+ const hasTasksDir = await this._pathExists(path.join(squadPath, 'tasks'));
197
+ const hasAgentsDir = await this._pathExists(path.join(squadPath, 'agents'));
198
+ const hasConfigDir = await this._pathExists(path.join(squadPath, 'config'));
199
+
200
+ const missingDirs = [];
201
+ if (!hasTasksDir) {
202
+ missingDirs.push('tasks');
203
+ }
204
+ if (!hasAgentsDir) {
205
+ missingDirs.push('agents');
206
+ }
207
+ if (!hasConfigDir) {
208
+ missingDirs.push('config');
209
+ }
210
+
211
+ if (missingDirs.length > 0) {
212
+ analysis.needsMigration = true;
213
+ analysis.issues.push({
214
+ type: 'FLAT_STRUCTURE',
215
+ message: `Missing task-first directories: ${missingDirs.join(', ')}`,
216
+ severity: 'warning',
217
+ });
218
+ analysis.actions.push({
219
+ type: 'CREATE_DIRECTORIES',
220
+ dirs: missingDirs,
221
+ });
222
+ }
223
+
224
+ // Check manifest schema compliance
225
+ const manifestPath = hasSquadYaml
226
+ ? path.join(squadPath, 'squad.yaml')
227
+ : path.join(squadPath, 'config.yaml');
228
+
229
+ try {
230
+ const content = await fs.readFile(manifestPath, 'utf-8');
231
+ const manifest = yaml.load(content);
232
+
233
+ // Check for missing aios.type
234
+ if (!manifest.aios?.type) {
235
+ analysis.needsMigration = true;
236
+ analysis.issues.push({
237
+ type: 'MISSING_AIOS_TYPE',
238
+ message: 'Missing required field: aios.type',
239
+ severity: 'error',
240
+ });
241
+ analysis.actions.push({
242
+ type: 'ADD_FIELD',
243
+ path: 'aios.type',
244
+ value: 'squad',
245
+ });
246
+ }
247
+
248
+ // Check for missing aios.minVersion
249
+ if (!manifest.aios?.minVersion) {
250
+ analysis.needsMigration = true;
251
+ analysis.issues.push({
252
+ type: 'MISSING_MIN_VERSION',
253
+ message: 'Missing required field: aios.minVersion',
254
+ severity: 'error',
255
+ });
256
+ analysis.actions.push({
257
+ type: 'ADD_FIELD',
258
+ path: 'aios.minVersion',
259
+ value: '2.1.0',
260
+ });
261
+ }
262
+
263
+ // Check for missing name field
264
+ if (!manifest.name) {
265
+ analysis.needsMigration = true;
266
+ analysis.issues.push({
267
+ type: 'MISSING_NAME',
268
+ message: 'Missing required field: name',
269
+ severity: 'error',
270
+ });
271
+ // Try to infer name from directory
272
+ const inferredName = path.basename(squadPath);
273
+ analysis.actions.push({
274
+ type: 'ADD_FIELD',
275
+ path: 'name',
276
+ value: inferredName,
277
+ });
278
+ }
279
+
280
+ // Check for missing version field
281
+ if (!manifest.version) {
282
+ analysis.needsMigration = true;
283
+ analysis.issues.push({
284
+ type: 'MISSING_VERSION',
285
+ message: 'Missing required field: version',
286
+ severity: 'error',
287
+ });
288
+ analysis.actions.push({
289
+ type: 'ADD_FIELD',
290
+ path: 'version',
291
+ value: '1.0.0',
292
+ });
293
+ }
294
+ } catch (error) {
295
+ if (error.name === 'YAMLException') {
296
+ throw new SquadMigratorError(
297
+ MigratorErrorCodes.MIGRATION_FAILED,
298
+ `Invalid YAML in manifest: ${error.message}`,
299
+ { squadPath, error: error.message }
300
+ );
301
+ }
302
+ throw error;
303
+ }
304
+
305
+ this._log(`Analysis complete. Needs migration: ${analysis.needsMigration}`);
306
+ this._log(`Issues found: ${analysis.issues.length}`);
307
+ this._log(`Actions planned: ${analysis.actions.length}`);
308
+
309
+ return analysis;
310
+ }
311
+
312
+ /**
313
+ * Create backup of squad before migration
314
+ * @param {string} squadPath - Path to squad directory
315
+ * @returns {Promise<string>} Path to backup directory
316
+ */
317
+ async createBackup(squadPath) {
318
+ const timestamp = Date.now();
319
+ const backupDir = path.join(squadPath, '.backup');
320
+ const backupPath = path.join(backupDir, `pre-migration-${timestamp}`);
321
+
322
+ this._log(`Creating backup at: ${backupPath}`);
323
+
324
+ try {
325
+ await fs.mkdir(backupPath, { recursive: true });
326
+
327
+ // Copy all files except .backup directory
328
+ const files = await fs.readdir(squadPath);
329
+ for (const file of files) {
330
+ if (file === '.backup') {
331
+ continue;
332
+ }
333
+ const src = path.join(squadPath, file);
334
+ const dest = path.join(backupPath, file);
335
+ await this._copyRecursive(src, dest);
336
+ }
337
+
338
+ this._log(`Backup created successfully`);
339
+ return backupPath;
340
+ } catch (error) {
341
+ throw new SquadMigratorError(
342
+ MigratorErrorCodes.BACKUP_FAILED,
343
+ `Failed to create backup: ${error.message}`,
344
+ { squadPath, error: error.message }
345
+ );
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Execute a single migration action
351
+ * @param {string} squadPath - Path to squad directory
352
+ * @param {MigrationAction} action - Action to execute
353
+ * @private
354
+ */
355
+ async _executeAction(squadPath, action) {
356
+ this._log(`Executing action: ${action.type}`);
357
+
358
+ switch (action.type) {
359
+ case 'RENAME_MANIFEST':
360
+ await fs.rename(
361
+ path.join(squadPath, action.from),
362
+ path.join(squadPath, action.to)
363
+ );
364
+ break;
365
+
366
+ case 'CREATE_DIRECTORIES':
367
+ for (const dir of action.dirs) {
368
+ await fs.mkdir(path.join(squadPath, dir), { recursive: true });
369
+ }
370
+ break;
371
+
372
+ case 'ADD_FIELD':
373
+ await this._addManifestField(squadPath, action.path, action.value);
374
+ break;
375
+
376
+ case 'MOVE_FILE':
377
+ await fs.rename(
378
+ path.join(squadPath, action.from),
379
+ path.join(squadPath, action.to)
380
+ );
381
+ break;
382
+
383
+ default:
384
+ throw new SquadMigratorError(
385
+ MigratorErrorCodes.MIGRATION_FAILED,
386
+ `Unknown action type: ${action.type}`,
387
+ { action }
388
+ );
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Add or update a field in the manifest YAML
394
+ * @param {string} squadPath - Path to squad directory
395
+ * @param {string} fieldPath - Dot-separated path to field
396
+ * @param {any} value - Value to set
397
+ * @private
398
+ */
399
+ async _addManifestField(squadPath, fieldPath, value) {
400
+ const manifestPath = path.join(squadPath, 'squad.yaml');
401
+
402
+ let content;
403
+ let manifest;
404
+
405
+ try {
406
+ content = await fs.readFile(manifestPath, 'utf-8');
407
+ manifest = yaml.load(content) || {};
408
+ } catch (error) {
409
+ if (error.code === 'ENOENT') {
410
+ // If squad.yaml doesn't exist yet, try config.yaml
411
+ const configPath = path.join(squadPath, 'config.yaml');
412
+ content = await fs.readFile(configPath, 'utf-8');
413
+ manifest = yaml.load(content) || {};
414
+ } else {
415
+ throw error;
416
+ }
417
+ }
418
+
419
+ // Navigate to nested path and set value
420
+ const parts = fieldPath.split('.');
421
+ let current = manifest;
422
+
423
+ for (let i = 0; i < parts.length - 1; i++) {
424
+ if (!current[parts[i]]) {
425
+ current[parts[i]] = {};
426
+ }
427
+ current = current[parts[i]];
428
+ }
429
+
430
+ current[parts[parts.length - 1]] = value;
431
+
432
+ // Write back to manifest
433
+ await fs.writeFile(manifestPath, yaml.dump(manifest, { lineWidth: -1 }), 'utf-8');
434
+
435
+ this._log(`Added field ${fieldPath} = ${value}`);
436
+ }
437
+
438
+ /**
439
+ * Migrate squad to current format
440
+ * @param {string} squadPath - Path to squad directory
441
+ * @returns {Promise<MigrationResult>}
442
+ */
443
+ async migrate(squadPath) {
444
+ this._log(`Starting migration for: ${squadPath}`);
445
+
446
+ // Analyze squad first
447
+ const analysis = await this.analyze(squadPath);
448
+
449
+ // If no migration needed, return early
450
+ if (!analysis.needsMigration) {
451
+ return {
452
+ success: true,
453
+ message: 'Squad is already up to date',
454
+ actions: [],
455
+ validation: null,
456
+ backupPath: null,
457
+ };
458
+ }
459
+
460
+ // Create backup (unless dry-run)
461
+ let backupPath = null;
462
+ if (!this.dryRun) {
463
+ backupPath = await this.createBackup(squadPath);
464
+ }
465
+
466
+ // Execute actions
467
+ const executedActions = [];
468
+
469
+ for (const action of analysis.actions) {
470
+ if (this.dryRun) {
471
+ executedActions.push({
472
+ ...action,
473
+ status: 'dry-run',
474
+ });
475
+ continue;
476
+ }
477
+
478
+ try {
479
+ await this._executeAction(squadPath, action);
480
+ executedActions.push({
481
+ ...action,
482
+ status: 'success',
483
+ });
484
+ } catch (error) {
485
+ executedActions.push({
486
+ ...action,
487
+ status: 'failed',
488
+ error: error.message,
489
+ });
490
+ }
491
+ }
492
+
493
+ // Check if any actions failed
494
+ const hasFailures = executedActions.some((a) => a.status === 'failed');
495
+
496
+ // Validate after migration (unless dry-run)
497
+ let validation = null;
498
+ if (!this.dryRun && this.validator) {
499
+ try {
500
+ validation = await this.validator.validate(squadPath);
501
+ } catch (error) {
502
+ this._log(`Validation error: ${error.message}`);
503
+ validation = { valid: false, error: error.message };
504
+ }
505
+ }
506
+
507
+ const result = {
508
+ success: !hasFailures,
509
+ message: hasFailures
510
+ ? 'Migration completed with errors'
511
+ : this.dryRun
512
+ ? 'Dry-run completed successfully'
513
+ : 'Migration completed successfully',
514
+ actions: executedActions,
515
+ validation,
516
+ backupPath,
517
+ };
518
+
519
+ this._log(`Migration complete. Success: ${result.success}`);
520
+
521
+ return result;
522
+ }
523
+
524
+ /**
525
+ * Generate migration report
526
+ * @param {MigrationAnalysis} analysis - Analysis result
527
+ * @param {MigrationResult} [result] - Migration result (if executed)
528
+ * @returns {string} Formatted report
529
+ */
530
+ generateReport(analysis, result = null) {
531
+ const lines = [];
532
+
533
+ lines.push('═══════════════════════════════════════════════════════════');
534
+ lines.push(' SQUAD MIGRATION REPORT');
535
+ lines.push('═══════════════════════════════════════════════════════════');
536
+ lines.push('');
537
+ lines.push(`Squad Path: ${analysis.squadPath}`);
538
+ lines.push(`Needs Migration: ${analysis.needsMigration ? 'Yes' : 'No'}`);
539
+ lines.push('');
540
+
541
+ // Issues section
542
+ if (analysis.issues.length > 0) {
543
+ lines.push('───────────────────────────────────────────────────────────');
544
+ lines.push('ISSUES FOUND:');
545
+ lines.push('───────────────────────────────────────────────────────────');
546
+ for (const issue of analysis.issues) {
547
+ const icon = issue.severity === 'error' ? '❌' : issue.severity === 'warning' ? '⚠️' : 'ℹ️';
548
+ lines.push(` ${icon} [${issue.severity.toUpperCase()}] ${issue.message}`);
549
+ }
550
+ lines.push('');
551
+ }
552
+
553
+ // Actions section
554
+ if (analysis.actions.length > 0) {
555
+ lines.push('───────────────────────────────────────────────────────────');
556
+ lines.push('PLANNED ACTIONS:');
557
+ lines.push('───────────────────────────────────────────────────────────');
558
+ for (let i = 0; i < analysis.actions.length; i++) {
559
+ const action = analysis.actions[i];
560
+ lines.push(` ${i + 1}. ${this._formatAction(action)}`);
561
+ }
562
+ lines.push('');
563
+ }
564
+
565
+ // Result section (if migration was executed)
566
+ if (result) {
567
+ lines.push('───────────────────────────────────────────────────────────');
568
+ lines.push('MIGRATION RESULT:');
569
+ lines.push('───────────────────────────────────────────────────────────');
570
+ lines.push(` Status: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`);
571
+ lines.push(` Message: ${result.message}`);
572
+
573
+ if (result.backupPath) {
574
+ lines.push(` Backup: ${result.backupPath}`);
575
+ }
576
+
577
+ if (result.actions.length > 0) {
578
+ lines.push('');
579
+ lines.push(' Executed Actions:');
580
+ for (const action of result.actions) {
581
+ const icon = action.status === 'success' ? '✅' : action.status === 'dry-run' ? '🔍' : '❌';
582
+ lines.push(` ${icon} ${this._formatAction(action)} [${action.status}]`);
583
+ if (action.error) {
584
+ lines.push(` Error: ${action.error}`);
585
+ }
586
+ }
587
+ }
588
+
589
+ if (result.validation) {
590
+ lines.push('');
591
+ lines.push(' Post-Migration Validation:');
592
+ lines.push(` Valid: ${result.validation.valid ? 'Yes' : 'No'}`);
593
+ if (result.validation.errors?.length > 0) {
594
+ lines.push(` Errors: ${result.validation.errors.length}`);
595
+ }
596
+ if (result.validation.warnings?.length > 0) {
597
+ lines.push(` Warnings: ${result.validation.warnings.length}`);
598
+ }
599
+ }
600
+ }
601
+
602
+ lines.push('');
603
+ lines.push('═══════════════════════════════════════════════════════════');
604
+
605
+ return lines.join('\n');
606
+ }
607
+
608
+ /**
609
+ * Format an action for display
610
+ * @param {MigrationAction} action - Action to format
611
+ * @returns {string}
612
+ * @private
613
+ */
614
+ _formatAction(action) {
615
+ switch (action.type) {
616
+ case 'RENAME_MANIFEST':
617
+ return `Rename ${action.from} → ${action.to}`;
618
+ case 'CREATE_DIRECTORIES':
619
+ return `Create directories: ${action.dirs.join(', ')}`;
620
+ case 'ADD_FIELD':
621
+ return `Add field: ${action.path} = "${action.value}"`;
622
+ case 'MOVE_FILE':
623
+ return `Move ${action.from} → ${action.to}`;
624
+ default:
625
+ return `${action.type}`;
626
+ }
627
+ }
628
+ }
629
+
630
+ module.exports = {
631
+ SquadMigrator,
632
+ SquadMigratorError,
633
+ MigratorErrorCodes,
634
+ };