#!/usr/bin/env node /** * BMAD Workspace Sync Utility * Cross-IDE context synchronization and restoration */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); /** * Get current session information */ function getCurrentSession(workspacePath) { const sessionsPath = path.join(workspacePath, 'sessions'); if (!fs.existsSync(sessionsPath)) { return null; } const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.json')); const now = new Date(); for (const file of sessionFiles) { try { const sessionData = JSON.parse(fs.readFileSync(path.join(sessionsPath, file), 'utf8')); const lastHeartbeat = new Date(sessionData.lastHeartbeat); const timeDiff = now - lastHeartbeat; // Consider session active if heartbeat within last hour if (timeDiff < 3600000) { return sessionData; } } catch (e) { // Skip corrupted session files } } return null; } /** * Update session heartbeat */ function updateSessionHeartbeat(workspacePath, sessionId) { const sessionFile = path.join(workspacePath, 'sessions', `${sessionId}.json`); if (fs.existsSync(sessionFile)) { try { const sessionData = JSON.parse(fs.readFileSync(sessionFile, 'utf8')); sessionData.lastHeartbeat = new Date().toISOString(); fs.writeFileSync(sessionFile, JSON.stringify(sessionData, null, 2)); return true; } catch (e) { console.warn('āš ļø Failed to update session heartbeat:', e.message); } } return false; } /** * Sync context from shared workspace */ function syncContextFromWorkspace(workspacePath) { const contextPath = path.join(workspacePath, 'context'); if (!fs.existsSync(contextPath)) { return { synced: [], errors: [] }; } const contextFiles = fs.readdirSync(contextPath); const synced = []; const errors = []; for (const file of contextFiles) { try { const filePath = path.join(contextPath, file); const stats = fs.statSync(filePath); if (stats.isFile() && (file.endsWith('.md') || file.endsWith('.json'))) { // Read context file for validation const content = fs.readFileSync(filePath, 'utf8'); if (content.trim().length > 0) { synced.push({ file: file, size: stats.size, modified: stats.mtime.toISOString(), preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') }); } } } catch (e) { errors.push(`Failed to sync ${file}: ${e.message}`); } } return { synced, errors }; } /** * Get latest progress updates */ function getLatestProgress(workspacePath, limit = 5) { const progressPath = path.join(workspacePath, 'progress'); if (!fs.existsSync(progressPath)) { return []; } const progressFiles = fs.readdirSync(progressPath) .filter(f => f.endsWith('.md')) .map(f => { try { const filePath = path.join(progressPath, f); const stats = fs.statSync(filePath); const content = fs.readFileSync(filePath, 'utf8'); return { file: f, modified: stats.mtime, size: stats.size, content: content, preview: content.substring(0, 150) + (content.length > 150 ? '...' : '') }; } catch (e) { return null; } }) .filter(Boolean) .sort((a, b) => b.modified - a.modified) .slice(0, limit); return progressFiles; } /** * Get pending handoffs */ function getPendingHandoffs(workspacePath) { const handoffsPath = path.join(workspacePath, 'handoffs'); if (!fs.existsSync(handoffsPath)) { return []; } const handoffFiles = fs.readdirSync(handoffsPath) .filter(f => f.endsWith('.json')) .map(f => { try { const content = fs.readFileSync(path.join(handoffsPath, f), 'utf8'); const handoff = JSON.parse(content); // Consider handoffs from last 24 hours as potentially relevant const handoffTime = new Date(handoff.timestamp); const timeDiff = new Date() - handoffTime; if (timeDiff < 86400000) { // 24 hours return handoff; } } catch (e) { // Skip corrupted handoff files } return null; }) .filter(Boolean) .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); return handoffFiles; } /** * Get recent quality reports */ function getRecentQualityReports(workspacePath, limit = 3) { const qualityPath = path.join(workspacePath, 'quality'); if (!fs.existsSync(qualityPath)) { return []; } const qualityFiles = fs.readdirSync(qualityPath) .filter(f => f.endsWith('.json') || f.endsWith('.md')) .map(f => { try { const filePath = path.join(qualityPath, f); const stats = fs.statSync(filePath); const content = fs.readFileSync(filePath, 'utf8'); return { file: f, modified: stats.mtime, type: f.endsWith('.json') ? 'report' : 'analysis', preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') }; } catch (e) { return null; } }) .filter(Boolean) .sort((a, b) => b.modified - a.modified) .slice(0, limit); return qualityFiles; } /** * Create sync summary */ function createSyncSummary(workspacePath, currentSession) { const contextSync = syncContextFromWorkspace(workspacePath); const latestProgress = getLatestProgress(workspacePath); const pendingHandoffs = getPendingHandoffs(workspacePath); const qualityReports = getRecentQualityReports(workspacePath); const summary = { timestamp: new Date().toISOString(), session: currentSession, context: { filesFound: contextSync.synced.length, syncErrors: contextSync.errors.length, files: contextSync.synced }, progress: { recentUpdates: latestProgress.length, updates: latestProgress }, handoffs: { pending: pendingHandoffs.length, recent: pendingHandoffs.slice(0, 3) }, quality: { recentReports: qualityReports.length, reports: qualityReports } }; return summary; } /** * Save sync state */ function saveSyncState(workspacePath, summary) { const syncPath = path.join(workspacePath, 'context', 'last-sync.json'); fs.writeFileSync(syncPath, JSON.stringify(summary, null, 2)); // Also create a readable markdown summary const markdownPath = path.join(workspacePath, 'context', 'sync-summary.md'); const markdownContent = generateSyncMarkdown(summary); fs.writeFileSync(markdownPath, markdownContent); return { syncFile: syncPath, markdownFile: markdownPath }; } /** * Generate sync markdown summary */ function generateSyncMarkdown(summary) { const sessionInfo = summary.session ? `**Current Session:** ${summary.session.id} (${summary.session.ide}) **User:** ${summary.session.user} **Last Activity:** ${new Date(summary.session.lastHeartbeat).toLocaleString()}` : '**No active session found**'; return `# Workspace Sync Summary **Sync Time:** ${new Date(summary.timestamp).toLocaleString()} ## Session Information ${sessionInfo} ## Context Files (${summary.context.filesFound}) ${summary.context.files.length > 0 ? summary.context.files.map(f => `- **${f.file}** (${f.size} bytes, modified: ${new Date(f.modified).toLocaleString()}) ${f.preview}` ).join('\n\n') : 'No context files found' } ${summary.context.syncErrors.length > 0 ? ` ## Sync Errors ${summary.context.syncErrors.map(e => `- ${e}`).join('\n')} ` : ''} ## Recent Progress (${summary.progress.recentUpdates}) ${summary.progress.updates.length > 0 ? summary.progress.updates.map(p => `- **${p.file}** (${new Date(p.modified).toLocaleString()}) ${p.preview}` ).join('\n\n') : 'No recent progress updates' } ## Pending Handoffs (${summary.handoffs.pending}) ${summary.handoffs.recent.length > 0 ? summary.handoffs.recent.map(h => `- **${h.id}:** ${h.fromAgent} → ${h.toAgent} Work: ${h.currentWork.substring(0, 80)}${h.currentWork.length > 80 ? '...' : ''} Time: ${new Date(h.timestamp).toLocaleString()}` ).join('\n\n') : 'No pending handoffs' } ## Quality Reports (${summary.quality.recentReports}) ${summary.quality.reports.length > 0 ? summary.quality.reports.map(q => `- **${q.file}** (${q.type}, ${new Date(q.modified).toLocaleString()}) ${q.preview}` ).join('\n\n') : 'No recent quality reports' } --- *Last synced: ${new Date(summary.timestamp).toLocaleString()}* *Generated by BMAD Cross-IDE Workspace System* `; } /** * Main sync function */ async function syncWorkspace(options = {}) { try { const workspacePath = path.join(process.cwd(), '.workspace'); if (!fs.existsSync(workspacePath)) { console.error('āŒ Workspace directory not found.'); console.error(' Run `npm run workspace-init` to initialize workspace'); process.exit(1); } console.log('šŸ”„ BMAD Workspace Sync'); console.log('====================='); console.log(`šŸ“ Workspace: ${workspacePath}`); // Get or create session let currentSession = getCurrentSession(workspacePath); if (!currentSession) { console.log('āš ļø No active session found, checking for workspace initialization...'); // Try to find the most recent session const sessionsPath = path.join(workspacePath, 'sessions'); if (fs.existsSync(sessionsPath)) { const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.json')); if (sessionFiles.length > 0) { // Get most recent session let mostRecent = null; let mostRecentTime = 0; for (const file of sessionFiles) { try { const sessionData = JSON.parse(fs.readFileSync(path.join(sessionsPath, file), 'utf8')); const created = new Date(sessionData.created).getTime(); if (created > mostRecentTime) { mostRecentTime = created; mostRecent = sessionData; } } catch (e) { // Skip corrupted files } } if (mostRecent) { console.log(`šŸ“ Using most recent session: ${mostRecent.id} (${mostRecent.ide})`); currentSession = mostRecent; } } } if (!currentSession) { console.error('āŒ No sessions found. Run `npm run workspace-init` first.'); process.exit(1); } } else { // Update heartbeat for active session updateSessionHeartbeat(workspacePath, currentSession.id); console.log(`āœ… Active session found: ${currentSession.id} (${currentSession.ide})`); } // Create comprehensive sync summary console.log('\nšŸ” Analyzing workspace state...'); const summary = createSyncSummary(workspacePath, currentSession); // Save sync state const files = saveSyncState(workspacePath, summary); // Log sync activity const logEntry = { timestamp: new Date().toISOString(), action: 'workspace-sync', sessionId: currentSession.id, contextFiles: summary.context.filesFound, progressUpdates: summary.progress.recentUpdates, pendingHandoffs: summary.handoffs.pending, user: process.env.USER || process.env.USERNAME || 'unknown' }; const logPath = path.join(workspacePath, 'logs', 'workspace.log'); fs.appendFileSync(logPath, JSON.stringify(logEntry) + '\n'); // Display sync results console.log('\nāœ… Workspace sync completed'); console.log('============================'); console.log(`šŸ“„ Context files: ${summary.context.filesFound}`); console.log(`šŸ“ˆ Progress updates: ${summary.progress.recentUpdates}`); console.log(`šŸ”„ Pending handoffs: ${summary.handoffs.pending}`); console.log(`šŸŽÆ Quality reports: ${summary.quality.recentReports}`); if (summary.context.syncErrors.length > 0) { console.log(`\nāš ļø Sync errors: ${summary.context.syncErrors.length}`); summary.context.syncErrors.forEach(error => console.log(` • ${error}`)); } console.log(`\nšŸ“ Sync summary: .workspace/context/sync-summary.md`); console.log(`šŸ“Š Detailed data: .workspace/context/last-sync.json`); // Show key highlights if (summary.handoffs.recent.length > 0) { console.log('\nšŸ”„ Recent Handoffs:'); summary.handoffs.recent.slice(0, 2).forEach(handoff => { console.log(` • ${handoff.fromAgent} → ${handoff.toAgent}: ${handoff.currentWork.substring(0, 60)}...`); }); } if (summary.progress.updates.length > 0) { console.log('\nšŸ“ˆ Latest Progress:'); console.log(` • ${summary.progress.updates[0].file}: ${summary.progress.updates[0].preview}`); } console.log('\nšŸš€ Workspace is now synchronized and ready for collaboration!'); } catch (error) { console.error('āŒ Failed to sync workspace:', error.message); process.exit(1); } } // Command line execution if (require.main === module) { const args = process.argv.slice(2); const options = { verbose: args.includes('--verbose'), force: args.includes('--force') }; syncWorkspace(options); } module.exports = { syncWorkspace, getCurrentSession, createSyncSummary };