aiwg
Version:
Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.
911 lines (794 loc) • 24.9 kB
text/typescript
/**
* WorkspaceMigrator - Migrate legacy .aiwg/ workspaces to framework-scoped structure
*
* Migrates legacy workspace structure (.aiwg/intake/, .aiwg/requirements/, etc.)
* to framework-scoped structure (.aiwg/frameworks/{framework-id}/projects/{project-id}/).
*
* Features:
* - Detection of legacy workspace structure
* - Framework detection from artifact patterns
* - Validation of migration safety (conflict detection)
* - Atomic migration with rollback capability
* - Backup creation before migration
* - Dry-run mode for simulation
* - Detailed migration reports
*
* @module src/plugin/workspace-migrator
* @version 1.0.0
* @since 2025-10-23
*
* @example
* ```typescript
* const migrator = new WorkspaceMigrator('/path/to/project');
* await migrator.initialize();
*
* // Detect legacy workspace
* const legacy = await migrator.detectLegacyWorkspace();
* if (legacy) {
* console.log(`Found legacy workspace with ${legacy.artifactCount} artifacts`);
*
* // Detect frameworks
* const frameworks = await migrator.detectFrameworks();
* console.log(`Detected frameworks: ${frameworks.map(f => f.name).join(', ')}`);
*
* // Validate migration
* const validation = await migrator.validateMigration({
* sourcePath: legacy.path,
* targetPath: `.aiwg/frameworks/${frameworks[0].name}/projects/default`,
* framework: frameworks[0].name
* });
*
* if (validation.safe) {
* // Perform migration
* const result = await migrator.migrate({
* source: legacy.path,
* target: `.aiwg/frameworks/${frameworks[0].name}/projects/default`,
* framework: frameworks[0].name,
* backup: true,
* dryRun: false,
* overwrite: false
* });
*
* console.log(migrator.generateReport(result));
* }
* }
* ```
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
// ===========================
// Interfaces
// ===========================
export interface LegacyWorkspaceInfo {
path: string;
artifactCount: number;
frameworks: string[]; // Detected framework traces
size: number; // bytes
hasGit: boolean;
}
export interface FrameworkInfo {
name: string;
path: string;
version?: string;
artifactCount: number;
}
export interface MigrationTarget {
sourcePath: string;
targetPath: string;
framework: string;
}
export interface ValidationResult {
safe: boolean;
warnings: string[];
conflicts: Conflict[];
estimatedDuration: number; // seconds
}
export interface Conflict {
type: 'file' | 'directory' | 'permission';
path: string;
description: string;
resolution: 'overwrite' | 'skip' | 'merge' | 'manual';
}
export interface MigrationOptions {
source: string;
target: string;
framework: string;
backup: boolean; // Create backup before migration
dryRun: boolean; // Simulate without actual changes
overwrite: boolean; // Overwrite existing files
}
export interface MigrationResult {
id: string;
success: boolean;
filesMovedCount: number;
filesCopiedCount: number;
filesSkippedCount: number;
errors: MigrationError[];
duration: number; // milliseconds
backupPath?: string;
}
export interface MigrationError {
path: string;
error: string;
severity: 'warning' | 'error' | 'critical';
}
// ===========================
// WorkspaceMigrator Class
// ===========================
export class WorkspaceMigrator {
private projectRoot: string;
private sandboxPath: string;
// Legacy workspace directories
private readonly LEGACY_DIRS = [
'intake',
'requirements',
'architecture',
'planning',
'risks',
'testing',
'security',
'quality',
'deployment',
'handoffs',
'gates',
'decisions',
'team',
'working',
'reports'
];
// Framework detection patterns
private readonly FRAMEWORK_PATTERNS = {
'sdlc-complete': [
'architecture/software-architecture-doc.md',
'requirements/use-cases/',
'testing/master-test-plan.md',
'deployment/deployment-plan.md'
],
'marketing-flow': [
'campaigns/',
'content/',
'analytics/'
],
'agile-complete': [
'backlog/',
'sprints/',
'retrospectives/'
]
};
constructor(projectRoot: string) {
this.projectRoot = path.resolve(projectRoot);
this.sandboxPath = path.join(this.projectRoot, '.aiwg');
}
/**
* Initialize the migrator
*/
async initialize(): Promise<void> {
// Verify project root exists
try {
await fs.access(this.projectRoot);
} catch {
throw new Error(`Project root does not exist: ${this.projectRoot}`);
}
}
// ===========================
// Detection Methods
// ===========================
/**
* Detect legacy workspace structure
*
* Returns information about legacy workspace if found, null otherwise.
*
* @returns Legacy workspace info or null if not found
*/
async detectLegacyWorkspace(): Promise<LegacyWorkspaceInfo | null> {
const legacyPath = this.sandboxPath;
try {
await fs.access(legacyPath);
} catch {
return null; // No .aiwg directory exists
}
// Check for legacy directory structure (intake, requirements, etc.)
const foundDirs: string[] = [];
let artifactCount = 0;
let totalSize = 0;
for (const dir of this.LEGACY_DIRS) {
const dirPath = path.join(legacyPath, dir);
try {
const stat = await fs.stat(dirPath);
if (stat.isDirectory()) {
foundDirs.push(dir);
const dirInfo = await this.getDirectoryInfo(dirPath);
artifactCount += dirInfo.fileCount;
totalSize += dirInfo.size;
}
} catch {
// Directory doesn't exist, skip
}
}
// If no legacy directories found, not a legacy workspace
if (foundDirs.length === 0) {
return null;
}
// Check for git repository
const hasGit = await this.hasGitRepo(legacyPath);
// Detect frameworks
const frameworks = await this.detectFrameworksFromArtifacts(legacyPath);
return {
path: legacyPath,
artifactCount,
frameworks,
size: totalSize,
hasGit
};
}
/**
* Detect frameworks from artifact structure
*
* Analyzes artifact patterns to identify which frameworks are in use.
*
* @returns Array of detected frameworks
*/
async detectFrameworks(): Promise<FrameworkInfo[]> {
const legacyPath = this.sandboxPath;
const frameworks: FrameworkInfo[] = [];
for (const [frameworkName, patterns] of Object.entries(this.FRAMEWORK_PATTERNS)) {
let matchCount = 0;
let artifactCount = 0;
for (const pattern of patterns) {
const fullPath = path.join(legacyPath, pattern);
try {
const stat = await fs.stat(fullPath);
if (stat.isFile() || stat.isDirectory()) {
matchCount++;
if (stat.isDirectory()) {
const dirInfo = await this.getDirectoryInfo(fullPath);
artifactCount += dirInfo.fileCount;
} else {
artifactCount++;
}
}
} catch {
// Pattern doesn't match, continue
}
}
// If at least 50% of patterns match, framework is detected
if (matchCount >= patterns.length * 0.5) {
frameworks.push({
name: frameworkName,
path: path.join(this.sandboxPath, 'frameworks', frameworkName, 'projects', 'default'),
artifactCount
});
}
}
// If no frameworks detected, default to sdlc-complete
if (frameworks.length === 0) {
const dirInfo = await this.getDirectoryInfo(legacyPath);
frameworks.push({
name: 'sdlc-complete',
path: path.join(this.sandboxPath, 'frameworks', 'sdlc-complete', 'projects', 'default'),
artifactCount: dirInfo.fileCount
});
}
return frameworks;
}
/**
* Detect frameworks from artifacts (internal helper)
*
* @param basePath - Base path to search
* @returns Array of framework names
*/
private async detectFrameworksFromArtifacts(basePath: string): Promise<string[]> {
const frameworks: string[] = [];
for (const [frameworkName, patterns] of Object.entries(this.FRAMEWORK_PATTERNS)) {
let matchCount = 0;
for (const pattern of patterns) {
const fullPath = path.join(basePath, pattern);
try {
await fs.access(fullPath);
matchCount++;
} catch {
// Pattern doesn't match
}
}
// If at least 50% of patterns match, framework is detected
if (matchCount >= patterns.length * 0.5) {
frameworks.push(frameworkName);
}
}
// Default to sdlc-complete if nothing detected
if (frameworks.length === 0) {
frameworks.push('sdlc-complete');
}
return frameworks;
}
// ===========================
// Validation Methods
// ===========================
/**
* Validate migration safety
*
* Checks for conflicts, permission issues, and estimates duration.
*
* @param target - Migration target configuration
* @returns Validation result
*/
async validateMigration(target: MigrationTarget): Promise<ValidationResult> {
const warnings: string[] = [];
const conflicts: Conflict[] = [];
// Check source exists
try {
await fs.access(target.sourcePath);
} catch {
return {
safe: false,
warnings: [`Source path does not exist: ${target.sourcePath}`],
conflicts: [],
estimatedDuration: 0
};
}
// Check target doesn't already exist (unless overwrite is allowed)
try {
await fs.access(target.targetPath);
warnings.push(`Target path already exists: ${target.targetPath}`);
// Check for file conflicts
const sourceFiles = await this.listFilesRecursive(target.sourcePath);
for (const relPath of sourceFiles) {
const targetFile = path.join(target.targetPath, relPath);
try {
await fs.access(targetFile);
conflicts.push({
type: 'file',
path: relPath,
description: 'File exists in target directory',
resolution: 'overwrite'
});
} catch {
// No conflict
}
}
} catch {
// Target doesn't exist, good
}
// Check permissions
const permissionIssues = await this.checkPermissions(target.sourcePath);
for (const issue of permissionIssues) {
conflicts.push({
type: 'permission',
path: issue,
description: 'Permission denied',
resolution: 'manual'
});
}
// Estimate duration (1ms per file + 100ms overhead)
const sourceFiles = await this.listFilesRecursive(target.sourcePath);
const estimatedDuration = Math.ceil((sourceFiles.length + 100) / 1000);
const safe = conflicts.filter(c => c.resolution === 'manual').length === 0;
return {
safe,
warnings,
conflicts,
estimatedDuration
};
}
/**
* Check for conflicts between source and target
*
* @param source - Source directory path
* @param target - Target directory path
* @returns Array of conflicts
*/
async checkConflicts(source: string, target: string): Promise<Conflict[]> {
const conflicts: Conflict[] = [];
try {
await fs.access(target);
} catch {
// Target doesn't exist, no conflicts
return conflicts;
}
const sourceFiles = await this.listFilesRecursive(source);
for (const relPath of sourceFiles) {
const targetFile = path.join(target, relPath);
try {
await fs.access(targetFile);
conflicts.push({
type: 'file',
path: relPath,
description: 'File exists in target directory',
resolution: 'overwrite'
});
} catch {
// No conflict
}
}
return conflicts;
}
// ===========================
// Migration Methods
// ===========================
/**
* Perform workspace migration
*
* Migrates legacy workspace to framework-scoped structure.
* Supports dry-run mode, backup creation, and atomic operations.
*
* @param options - Migration options
* @returns Migration result
*/
async migrate(options: MigrationOptions): Promise<MigrationResult> {
const startTime = Date.now();
const migrationId = this.generateMigrationId();
const errors: MigrationError[] = [];
let filesMovedCount = 0;
let filesCopiedCount = 0;
let filesSkippedCount = 0;
let backupPath: string | undefined;
try {
// Validate source exists
try {
await fs.access(options.source);
} catch {
throw new Error(`Source path does not exist: ${options.source}`);
}
// Create backup if requested
if (options.backup && !options.dryRun) {
backupPath = await this.createBackup(options.source, migrationId);
}
// Get list of files to migrate
const sourceFiles = await this.listFilesRecursive(options.source);
// Filter out framework and registry directories (shouldn't be in legacy workspace)
const filesToMigrate = sourceFiles.filter(f =>
!f.startsWith('frameworks/') && f !== 'frameworks' &&
!f.startsWith('frameworks/registry.json')
);
// Dry-run mode: simulate without actual changes
if (options.dryRun) {
for (const relPath of filesToMigrate) {
const targetPath = path.join(options.target, relPath);
try {
await fs.access(targetPath);
if (options.overwrite) {
filesCopiedCount++;
} else {
filesSkippedCount++;
}
} catch {
filesCopiedCount++;
}
}
} else {
// Actual migration
// Create target directory
await fs.mkdir(options.target, { recursive: true });
// Migrate each file
for (const relPath of filesToMigrate) {
const sourcePath = path.join(options.source, relPath);
const targetPath = path.join(options.target, relPath);
try {
// Check if target exists
let targetExists = false;
try {
await fs.access(targetPath);
targetExists = true;
} catch {
// Target doesn't exist
}
if (targetExists && !options.overwrite) {
filesSkippedCount++;
errors.push({
path: relPath,
error: 'Target file exists, skipped (overwrite=false)',
severity: 'warning'
});
continue;
}
// Create target directory
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(sourcePath, targetPath);
filesCopiedCount++;
} catch (error: any) {
errors.push({
path: relPath,
error: error.message,
severity: 'error'
});
}
}
// Update registry
await this.updateRegistry(options.framework, migrationId);
}
const duration = Date.now() - startTime;
return {
id: migrationId,
success: errors.filter(e => e.severity === 'critical').length === 0,
filesMovedCount,
filesCopiedCount,
filesSkippedCount,
errors,
duration,
backupPath
};
} catch (error: any) {
const duration = Date.now() - startTime;
return {
id: migrationId,
success: false,
filesMovedCount,
filesCopiedCount,
filesSkippedCount,
errors: [{
path: options.source,
error: error.message,
severity: 'critical'
}],
duration,
backupPath
};
}
}
/**
* Rollback migration
*
* Restores workspace from backup created during migration.
*
* @param migrationId - Migration ID to rollback
*/
async rollback(migrationId: string): Promise<void> {
const backupPath = path.join(this.sandboxPath, 'backups', migrationId);
try {
await fs.access(backupPath);
} catch {
throw new Error(`Backup not found for migration ID: ${migrationId}`);
}
// Restore from backup
const files = await this.listFilesRecursive(backupPath);
for (const relPath of files) {
const sourcePath = path.join(backupPath, relPath);
const targetPath = path.join(this.sandboxPath, relPath);
// Create target directory
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(sourcePath, targetPath);
}
}
// ===========================
// Reporting Methods
// ===========================
/**
* Generate migration report
*
* Creates human-readable migration report with statistics and errors.
*
* @param result - Migration result
* @returns Formatted report string
*/
generateReport(result: MigrationResult): string {
const lines: string[] = [];
lines.push('Migration Report');
lines.push('='.repeat(80));
lines.push('');
lines.push(`Migration ID: ${result.id}`);
lines.push(`Status: ${result.success ? '✓ SUCCESS' : '❌ FAILED'}`);
lines.push(`Duration: ${result.duration}ms`);
lines.push('');
lines.push('Statistics:');
lines.push(` Files Moved: ${result.filesMovedCount}`);
lines.push(` Files Copied: ${result.filesCopiedCount}`);
lines.push(` Files Skipped: ${result.filesSkippedCount}`);
lines.push('');
if (result.backupPath) {
lines.push(`Backup Created: ${result.backupPath}`);
lines.push('');
}
if (result.errors.length > 0) {
lines.push('Errors:');
result.errors.forEach((error, index) => {
const icon = error.severity === 'critical' ? '❌' :
error.severity === 'error' ? '⚠️' : 'ℹ️';
lines.push(` ${index + 1}. ${icon} [${error.severity.toUpperCase()}] ${error.path}`);
lines.push(` ${error.error}`);
});
} else {
lines.push('No errors encountered.');
}
return lines.join('\n');
}
// ===========================
// Helper Methods
// ===========================
/**
* Get directory information (file count and size)
*/
private async getDirectoryInfo(dirPath: string): Promise<{ fileCount: number; size: number }> {
let fileCount = 0;
let size = 0;
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subInfo = await this.getDirectoryInfo(fullPath);
fileCount += subInfo.fileCount;
size += subInfo.size;
} else {
fileCount++;
const stat = await fs.stat(fullPath);
size += stat.size;
}
}
} catch {
// Permission denied or doesn't exist
}
return { fileCount, size };
}
/**
* Check if directory has git repository
*/
private async hasGitRepo(dirPath: string): Promise<boolean> {
const gitPath = path.join(dirPath, '.git');
try {
await fs.access(gitPath);
return true;
} catch {
return false;
}
}
/**
* List all files recursively
*/
private async listFilesRecursive(dirPath: string): Promise<string[]> {
const files: string[] = [];
const walk = async (currentPath: string, relativePath: string = '') => {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
const relPath = path.join(relativePath, entry.name);
if (entry.isDirectory()) {
await walk(fullPath, relPath);
} else {
files.push(relPath);
}
}
};
await walk(dirPath);
return files;
}
/**
* Check permissions for all files in directory
*/
private async checkPermissions(dirPath: string): Promise<string[]> {
const issues: string[] = [];
const walk = async (currentPath: string, relativePath: string = '') => {
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
const relPath = path.join(relativePath, entry.name);
try {
await fs.access(fullPath, fs.constants.R_OK);
} catch {
issues.push(relPath);
}
if (entry.isDirectory()) {
await walk(fullPath, relPath);
}
}
} catch {
issues.push(relativePath);
}
};
await walk(dirPath);
return issues;
}
/**
* Create backup of workspace
*/
private async createBackup(sourcePath: string, migrationId: string): Promise<string> {
const backupDir = path.join(this.sandboxPath, 'backups');
const backupPath = path.join(backupDir, migrationId);
await fs.mkdir(backupPath, { recursive: true });
// Copy all files
const files = await this.listFilesRecursive(sourcePath);
for (const relPath of files) {
const source = path.join(sourcePath, relPath);
const target = path.join(backupPath, relPath);
// Create target directory
const targetDir = path.dirname(target);
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(source, target);
}
return backupPath;
}
/**
* Update framework registry after migration
*/
private async updateRegistry(frameworkId: string, migrationId: string): Promise<void> {
const registryPath = path.join(this.sandboxPath, 'frameworks', 'registry.json');
try {
// Load or create registry
let registry: {
version: string;
lastModified: string;
plugins: Array<{
id: string;
type: string;
name: string;
version: string;
path: string;
installedAt: string;
projects?: string[];
health?: {
status: string;
lastCheck: string;
};
metadata?: {
migrationId?: string;
migratedAt?: string;
};
}>;
};
try {
const content = await fs.readFile(registryPath, 'utf-8');
registry = JSON.parse(content);
} catch (error: any) {
// Create new registry if it doesn't exist
registry = {
version: '1.0.0',
lastModified: new Date().toISOString(),
plugins: []
};
}
// Check if framework already exists
const existingIndex = registry.plugins.findIndex(p => p.id === frameworkId);
const pluginEntry = {
id: frameworkId,
type: 'framework' as const,
name: frameworkId.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
version: '1.0.0',
path: `frameworks/${frameworkId}`,
installedAt: new Date().toISOString(),
projects: ['default'],
health: {
status: 'healthy',
lastCheck: new Date().toISOString()
},
metadata: {
migrationId,
migratedAt: new Date().toISOString()
}
};
if (existingIndex >= 0) {
// Update existing entry
registry.plugins[existingIndex] = {
...registry.plugins[existingIndex],
...pluginEntry,
installedAt: registry.plugins[existingIndex].installedAt // Preserve original install date
};
} else {
// Add new entry
registry.plugins.push(pluginEntry);
}
registry.lastModified = new Date().toISOString();
// Ensure directory exists
await fs.mkdir(path.dirname(registryPath), { recursive: true });
// Write registry
await fs.writeFile(registryPath, JSON.stringify(registry, null, 2));
console.debug(`[WorkspaceMigrator] Registry updated for framework: ${frameworkId}, migration: ${migrationId}`);
} catch (error: any) {
console.warn(`[WorkspaceMigrator] Failed to update registry: ${error.message}`);
// Non-fatal error - migration still succeeded
}
}
/**
* Generate unique migration ID
*/
private generateMigrationId(): string {
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
return `migration-${timestamp}-${random}`;
}
}