BMAD-METHOD/tools/installer/lib/context-manager.js

1059 lines
36 KiB
JavaScript

const fs = require('fs');
const path = require('path');
class ContextManager {
constructor(workspacePath = null) {
this.workspacePath = workspacePath || path.join(process.cwd(), '.workspace');
this.contextPath = path.join(this.workspacePath, 'context');
this.decisionsPath = path.join(this.workspacePath, 'decisions');
this.progressPath = path.join(this.workspacePath, 'progress');
this.qualityPath = path.join(this.workspacePath, 'quality');
this.archivePath = path.join(this.workspacePath, 'archive');
this.versionsPath = path.join(this.workspacePath, 'versions');
this.locksPath = path.join(this.workspacePath, 'locks');
// Context file size threshold for compaction (10MB default)
this.maxContextSize = 10 * 1024 * 1024;
// Context versioning settings
this.maxVersions = 50; // Keep last 50 versions
this.conflictResolutionStrategy = 'merge'; // 'merge', 'overwrite', 'manual'
// Initialize if needed
this.initialize();
}
initialize() {
// Ensure all context directories exist
const dirs = [this.contextPath, this.decisionsPath, this.progressPath, this.qualityPath, this.archivePath, this.versionsPath, this.locksPath];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
// SHARED CONTEXT MANAGEMENT
async updateSharedContext(updates) {
try {
const contextFile = path.join(this.contextPath, 'shared-context.md');
let context = await this.loadSharedContext();
// Merge updates with existing context
const updatedContext = {
...context,
...updates,
lastUpdated: new Date().toISOString()
};
const contextContent = this.formatSharedContext(updatedContext);
fs.writeFileSync(contextFile, contextContent);
// Check if compaction is needed
await this.checkContextCompaction(contextFile);
return updatedContext;
} catch (error) {
console.error('Failed to update shared context:', error.message);
throw error;
}
}
async loadSharedContext() {
try {
const contextFile = path.join(this.contextPath, 'shared-context.md');
if (!fs.existsSync(contextFile)) {
return this.getDefaultSharedContext();
}
const content = fs.readFileSync(contextFile, 'utf8');
return this.parseSharedContext(content);
} catch (error) {
console.error('Failed to load shared context:', error.message);
return this.getDefaultSharedContext();
}
}
getDefaultSharedContext() {
return {
lastUpdated: new Date().toISOString(),
activeSessions: [],
primaryAgent: 'unknown',
currentFocus: 'No active development focus',
keyDecisions: [],
nextSteps: [],
sessionNotes: ''
};
}
formatSharedContext(context) {
return `# Workspace Context
**Last Updated:** ${context.lastUpdated}
**Active Sessions:** ${context.activeSessions.join(', ') || 'None'}
**Primary Agent:** ${context.primaryAgent}
## Current Focus
${context.currentFocus}
## Key Decisions
${context.keyDecisions.map(decision => `- ${decision}`).join('\n') || '- No decisions recorded yet'}
## Next Steps
${context.nextSteps.map(step => `- ${step}`).join('\n') || '- No next steps defined yet'}
## Session Notes
${context.sessionNotes || 'No session notes available'}
`;
}
parseSharedContext(content) {
const context = this.getDefaultSharedContext();
try {
// Extract metadata
const lastUpdatedMatch = content.match(/\*\*Last Updated:\*\* (.+)/);
if (lastUpdatedMatch) context.lastUpdated = lastUpdatedMatch[1];
const activeSessionsMatch = content.match(/\*\*Active Sessions:\*\* (.+)/);
if (activeSessionsMatch && activeSessionsMatch[1] !== 'None') {
context.activeSessions = activeSessionsMatch[1].split(', ').map(s => s.trim());
}
const primaryAgentMatch = content.match(/\*\*Primary Agent:\*\* (.+)/);
if (primaryAgentMatch) context.primaryAgent = primaryAgentMatch[1];
// Extract sections
const currentFocusMatch = content.match(/## Current Focus\n([\s\S]*?)(?=\n## |$)/);
if (currentFocusMatch) context.currentFocus = currentFocusMatch[1].trim();
const keyDecisionsMatch = content.match(/## Key Decisions\n([\s\S]*?)(?=\n## |$)/);
if (keyDecisionsMatch) {
context.keyDecisions = keyDecisionsMatch[1]
.split('\n')
.filter(line => line.startsWith('- '))
.map(line => line.substring(2).trim())
.filter(decision => decision && !decision.includes('No decisions recorded'));
}
const nextStepsMatch = content.match(/## Next Steps\n([\s\S]*?)(?=\n## |$)/);
if (nextStepsMatch) {
context.nextSteps = nextStepsMatch[1]
.split('\n')
.filter(line => line.startsWith('- '))
.map(line => line.substring(2).trim())
.filter(step => step && !step.includes('No next steps defined'));
}
const sessionNotesMatch = content.match(/## Session Notes\n([\s\S]*?)$/);
if (sessionNotesMatch) context.sessionNotes = sessionNotesMatch[1].trim();
} catch (error) {
console.warn('Failed to parse shared context, using defaults:', error.message);
}
return context;
}
// DECISIONS LOGGING
async logDecision(decision) {
try {
const decisionsFile = path.join(this.decisionsPath, 'decisions-log.md');
let existingContent = '';
if (fs.existsSync(decisionsFile)) {
existingContent = fs.readFileSync(decisionsFile, 'utf8');
} else {
existingContent = '# Architectural & Design Decisions\n\n';
}
// Generate decision ID
const decisionCount = (existingContent.match(/## Decision \d+:/g) || []).length;
const decisionId = String(decisionCount + 1).padStart(3, '0');
const decisionEntry = `## Decision ${decisionId}: ${decision.title}
**Date:** ${decision.date || new Date().toISOString()}
**Agent:** ${decision.agent || 'unknown'}
**Context:** ${decision.context || 'No context provided'}
**Decision:** ${decision.decision}
**Rationale:** ${decision.rationale || 'No rationale provided'}
**Alternatives:** ${decision.alternatives || 'No alternatives considered'}
**Impact:** ${decision.impact || 'Impact not assessed'}
**Status:** ${decision.status || 'active'}
`;
const updatedContent = existingContent + decisionEntry;
fs.writeFileSync(decisionsFile, updatedContent);
// Update shared context with new decision
const context = await this.loadSharedContext();
context.keyDecisions.push(`${decision.title} (${decision.date || new Date().toISOString().split('T')[0]})`);
await this.updateSharedContext(context);
return decisionId;
} catch (error) {
console.error('Failed to log decision:', error.message);
throw error;
}
}
async getDecisions(filters = {}) {
try {
const decisionsFile = path.join(this.decisionsPath, 'decisions-log.md');
if (!fs.existsSync(decisionsFile)) {
return [];
}
const content = fs.readFileSync(decisionsFile, 'utf8');
const decisions = this.parseDecisions(content);
// Apply filters
let filteredDecisions = decisions;
if (filters.agent) {
filteredDecisions = filteredDecisions.filter(d => d.agent === filters.agent);
}
if (filters.status) {
filteredDecisions = filteredDecisions.filter(d => d.status === filters.status);
}
if (filters.dateFrom) {
const fromDate = new Date(filters.dateFrom);
filteredDecisions = filteredDecisions.filter(d => new Date(d.date) >= fromDate);
}
if (filters.dateTo) {
const toDate = new Date(filters.dateTo);
filteredDecisions = filteredDecisions.filter(d => new Date(d.date) <= toDate);
}
return filteredDecisions;
} catch (error) {
console.error('Failed to get decisions:', error.message);
return [];
}
}
parseDecisions(content) {
const decisions = [];
const decisionBlocks = content.split(/## Decision \d+:/);
for (let i = 1; i < decisionBlocks.length; i++) {
try {
const block = decisionBlocks[i];
const lines = block.split('\n');
const decision = {
id: `${i.toString().padStart(3, '0')}`,
title: lines[0].trim(),
date: this.extractField(block, 'Date'),
agent: this.extractField(block, 'Agent'),
context: this.extractField(block, 'Context'),
decision: this.extractField(block, 'Decision'),
rationale: this.extractField(block, 'Rationale'),
alternatives: this.extractField(block, 'Alternatives'),
impact: this.extractField(block, 'Impact'),
status: this.extractField(block, 'Status')
};
decisions.push(decision);
} catch (error) {
console.warn(`Failed to parse decision block ${i}:`, error.message);
}
}
return decisions;
}
extractField(content, fieldName) {
const regex = new RegExp(`\\*\\*${fieldName}:\\*\\* (.+)`, 'i');
const match = content.match(regex);
return match ? match[1].trim() : '';
}
// PROGRESS TRACKING
async updateProgress(progressUpdate) {
try {
const progressFile = path.join(this.progressPath, 'progress-summary.md');
let progress = await this.loadProgress();
// Merge updates
const updatedProgress = {
...progress,
...progressUpdate,
lastUpdated: new Date().toISOString()
};
const progressContent = this.formatProgress(updatedProgress);
fs.writeFileSync(progressFile, progressContent);
// Update shared context
const context = await this.loadSharedContext();
if (progressUpdate.currentStory) {
context.currentFocus = progressUpdate.currentStory;
}
if (progressUpdate.nextSteps) {
context.nextSteps = progressUpdate.nextSteps;
}
await this.updateSharedContext(context);
return updatedProgress;
} catch (error) {
console.error('Failed to update progress:', error.message);
throw error;
}
}
async loadProgress() {
try {
const progressFile = path.join(this.progressPath, 'progress-summary.md');
if (!fs.existsSync(progressFile)) {
return this.getDefaultProgress();
}
const content = fs.readFileSync(progressFile, 'utf8');
return this.parseProgress(content);
} catch (error) {
console.error('Failed to load progress:', error.message);
return this.getDefaultProgress();
}
}
getDefaultProgress() {
return {
lastUpdated: new Date().toISOString(),
currentStory: 'No active story',
completedTasks: [],
pendingTasks: [],
blockers: [],
qualityScore: 'Not assessed'
};
}
formatProgress(progress) {
return `# Development Progress Summary
**Last Updated:** ${progress.lastUpdated}
**Current Story:** ${progress.currentStory}
**Quality Score:** ${progress.qualityScore}
## Completed Tasks
${progress.completedTasks.map(task => `- ✅ ${task}`).join('\n') || '- No tasks completed yet'}
## Pending Tasks
${progress.pendingTasks.map(task => `- ⏳ ${task}`).join('\n') || '- No pending tasks'}
## Blockers
${progress.blockers.map(blocker => `- 🚫 ${blocker}`).join('\n') || '- No blockers identified'}
`;
}
parseProgress(content) {
const progress = this.getDefaultProgress();
try {
// Extract metadata
const lastUpdatedMatch = content.match(/\*\*Last Updated:\*\* (.+)/);
if (lastUpdatedMatch) progress.lastUpdated = lastUpdatedMatch[1];
const currentStoryMatch = content.match(/\*\*Current Story:\*\* (.+)/);
if (currentStoryMatch) progress.currentStory = currentStoryMatch[1];
const qualityScoreMatch = content.match(/\*\*Quality Score:\*\* (.+)/);
if (qualityScoreMatch) progress.qualityScore = qualityScoreMatch[1];
// Extract task lists
const completedMatch = content.match(/## Completed Tasks\n([\s\S]*?)(?=\n## |$)/);
if (completedMatch) {
progress.completedTasks = completedMatch[1]
.split('\n')
.filter(line => line.startsWith('- ✅'))
.map(line => line.substring(4).trim())
.filter(task => task && !task.includes('No tasks completed'));
}
const pendingMatch = content.match(/## Pending Tasks\n([\s\S]*?)(?=\n## |$)/);
if (pendingMatch) {
progress.pendingTasks = pendingMatch[1]
.split('\n')
.filter(line => line.startsWith('- ⏳'))
.map(line => line.substring(4).trim())
.filter(task => task && !task.includes('No pending tasks'));
}
const blockersMatch = content.match(/## Blockers\n([\s\S]*?)$/);
if (blockersMatch) {
progress.blockers = blockersMatch[1]
.split('\n')
.filter(line => line.startsWith('- 🚫'))
.map(line => line.substring(4).trim())
.filter(blocker => blocker && !blocker.includes('No blockers'));
}
} catch (error) {
console.warn('Failed to parse progress, using defaults:', error.message);
}
return progress;
}
// QUALITY METRICS
async updateQualityMetrics(metrics) {
try {
const qualityFile = path.join(this.qualityPath, 'quality-metrics.md');
const timestamp = new Date().toISOString();
let existingContent = '';
if (fs.existsSync(qualityFile)) {
existingContent = fs.readFileSync(qualityFile, 'utf8');
} else {
existingContent = '# Quality Metrics History\n\n';
}
const metricsEntry = `## Quality Assessment - ${timestamp}
**Agent:** ${metrics.agent || 'unknown'}
**Story:** ${metrics.story || 'unknown'}
**Reality Audit Score:** ${metrics.realityAuditScore || 'N/A'}
**Pattern Compliance:** ${metrics.patternCompliance || 'N/A'}
**Technical Debt Score:** ${metrics.technicalDebtScore || 'N/A'}
**Overall Quality:** ${metrics.overallQuality || 'N/A'}
**Details:**
${metrics.details || 'No additional details provided'}
---
`;
const updatedContent = existingContent + metricsEntry;
fs.writeFileSync(qualityFile, updatedContent);
// Update progress with quality score
const progress = await this.loadProgress();
progress.qualityScore = metrics.overallQuality || metrics.realityAuditScore || 'Updated';
await this.updateProgress(progress);
return metrics;
} catch (error) {
console.error('Failed to update quality metrics:', error.message);
throw error;
}
}
async getLatestQualityMetrics() {
try {
const qualityFile = path.join(this.qualityPath, 'quality-metrics.md');
if (!fs.existsSync(qualityFile)) {
return null;
}
const content = fs.readFileSync(qualityFile, 'utf8');
const assessments = content.split('## Quality Assessment -');
if (assessments.length < 2) {
return null;
}
// Get the most recent assessment
const latestAssessment = assessments[1];
return {
timestamp: latestAssessment.split('\n')[0].trim(),
agent: this.extractField(latestAssessment, 'Agent'),
story: this.extractField(latestAssessment, 'Story'),
realityAuditScore: this.extractField(latestAssessment, 'Reality Audit Score'),
patternCompliance: this.extractField(latestAssessment, 'Pattern Compliance'),
technicalDebtScore: this.extractField(latestAssessment, 'Technical Debt Score'),
overallQuality: this.extractField(latestAssessment, 'Overall Quality'),
details: this.extractField(latestAssessment, 'Details')
};
} catch (error) {
console.error('Failed to get latest quality metrics:', error.message);
return null;
}
}
// CONTEXT COMPACTION
async checkContextCompaction(filePath) {
try {
const stats = fs.statSync(filePath);
if (stats.size > this.maxContextSize) {
await this.compactContext(filePath);
}
} catch (error) {
console.error('Failed to check context compaction:', error.message);
}
}
async compactContext(filePath) {
try {
const fileName = path.basename(filePath);
const archiveFileName = `archived-${Date.now()}-${fileName}`;
const archiveFilePath = path.join(this.archivePath, archiveFileName);
// Move original to archive
const originalContent = fs.readFileSync(filePath, 'utf8');
fs.writeFileSync(archiveFilePath, originalContent);
// Create summarized version
const summarizedContent = await this.summarizeContext(originalContent, fileName);
fs.writeFileSync(filePath, summarizedContent);
console.log(`Context compacted: ${fileName} archived as ${archiveFileName}`);
} catch (error) {
console.error('Failed to compact context:', error.message);
}
}
async summarizeContext(content, fileName) {
// Intelligent summarization preserving key decisions and recent activity
const timestamp = new Date().toISOString();
if (fileName === 'shared-context.md') {
const context = this.parseSharedContext(content);
// Keep only most recent 5 decisions and next steps
context.keyDecisions = context.keyDecisions.slice(-5);
context.nextSteps = context.nextSteps.slice(-5);
context.sessionNotes = `[Archived ${timestamp}] Context compacted - full history available in archive.`;
return this.formatSharedContext(context);
}
// For other file types, create a basic summary
return `# ${fileName.replace('.md', '')} - Compacted Summary
**Compacted:** ${timestamp}
**Original Size:** ${content.length} characters
This file was automatically compacted to manage size. Full historical context is available in the archive directory.
**Recent Activity Summary:**
${content.substring(content.length - 1000)}
[Full historical context archived - use archive restoration if detailed history is needed]
`;
}
// SESSION INTEGRATION
async onSessionStart(sessionId, agent) {
try {
const context = await this.loadSharedContext();
if (!context.activeSessions.includes(sessionId)) {
context.activeSessions.push(sessionId);
}
context.primaryAgent = agent;
context.sessionNotes = `Session ${sessionId} started by ${agent} at ${new Date().toISOString()}`;
await this.updateSharedContext(context);
} catch (error) {
console.error('Failed to handle session start:', error.message);
}
}
async onSessionEnd(sessionId) {
try {
const context = await this.loadSharedContext();
context.activeSessions = context.activeSessions.filter(id => id !== sessionId);
context.sessionNotes += `\nSession ${sessionId} ended at ${new Date().toISOString()}`;
await this.updateSharedContext(context);
} catch (error) {
console.error('Failed to handle session end:', error.message);
}
}
// UTILITY METHODS
async getWorkspaceStatus() {
try {
const context = await this.loadSharedContext();
const progress = await this.loadProgress();
const latestQuality = await this.getLatestQualityMetrics();
const recentDecisions = await this.getDecisions({ dateFrom: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() });
return {
context,
progress,
latestQuality,
recentDecisions: recentDecisions.slice(-5)
};
} catch (error) {
console.error('Failed to get workspace status:', error.message);
return null;
}
}
async exportContextSummary() {
try {
const status = await this.getWorkspaceStatus();
return `# Workspace Context Export
**Generated:** ${new Date().toISOString()}
## Current Status
- **Primary Agent:** ${status.context.primaryAgent}
- **Active Sessions:** ${status.context.activeSessions.join(', ') || 'None'}
- **Current Focus:** ${status.context.currentFocus}
- **Quality Score:** ${status.progress.qualityScore}
## Recent Decisions (Last 7 Days)
${status.recentDecisions.map(d => `- ${d.title} (${d.agent})`).join('\n') || '- No recent decisions'}
## Progress Summary
- **Completed Tasks:** ${status.progress.completedTasks.length}
- **Pending Tasks:** ${status.progress.pendingTasks.length}
- **Blockers:** ${status.progress.blockers.length}
## Next Steps
${status.context.nextSteps.map(step => `- ${step}`).join('\n') || '- No next steps defined'}
`;
} catch (error) {
console.error('Failed to export context summary:', error.message);
return 'Error generating context summary';
}
}
// CONTEXT VERSIONING AND CONFLICT RESOLUTION
async createContextVersion(contextType, content, sessionId, agent) {
try {
const timestamp = new Date().toISOString();
const versionId = `${contextType}-${timestamp.replace(/[:.]/g, '-')}-${sessionId}`;
const versionFile = path.join(this.versionsPath, `${versionId}.json`);
const version = {
id: versionId,
contextType,
timestamp,
sessionId,
agent,
content,
hash: this.generateContentHash(content)
};
fs.writeFileSync(versionFile, JSON.stringify(version, null, 2));
// Cleanup old versions
await this.cleanupOldVersions(contextType);
return versionId;
} catch (error) {
console.error('Failed to create context version:', error.message);
throw error;
}
}
generateContentHash(content) {
// Simple hash function for content comparison
let hash = 0;
if (content.length === 0) return hash;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString();
}
async cleanupOldVersions(contextType) {
try {
const versionFiles = fs.readdirSync(this.versionsPath)
.filter(file => file.startsWith(contextType) && file.endsWith('.json'))
.map(file => {
const filePath = path.join(this.versionsPath, file);
const stats = fs.statSync(filePath);
return { file, path: filePath, mtime: stats.mtime };
})
.sort((a, b) => b.mtime - a.mtime);
// Keep only the most recent versions
if (versionFiles.length > this.maxVersions) {
const filesToDelete = versionFiles.slice(this.maxVersions);
for (const fileInfo of filesToDelete) {
fs.unlinkSync(fileInfo.path);
}
}
} catch (error) {
console.error('Failed to cleanup old versions:', error.message);
}
}
async detectContextConflicts(contextType, newContent, sessionId) {
try {
const currentContextFile = path.join(this.getContextFilePath(contextType));
if (!fs.existsSync(currentContextFile)) {
return { hasConflict: false, conflict: null };
}
const currentContent = fs.readFileSync(currentContextFile, 'utf8');
const currentHash = this.generateContentHash(currentContent);
const newHash = this.generateContentHash(newContent);
if (currentHash === newHash) {
return { hasConflict: false, conflict: null };
}
// Check if there are concurrent modifications
const recentVersions = await this.getRecentVersions(contextType, 5);
const concurrentVersions = recentVersions.filter(v =>
v.sessionId !== sessionId &&
new Date() - new Date(v.timestamp) < 5 * 60 * 1000 // Within last 5 minutes
);
if (concurrentVersions.length > 0) {
return {
hasConflict: true,
conflict: {
type: 'concurrent_modification',
currentContent,
newContent,
concurrentVersions
}
};
}
return { hasConflict: false, conflict: null };
} catch (error) {
console.error('Failed to detect context conflicts:', error.message);
return { hasConflict: false, conflict: null };
}
}
getContextFilePath(contextType) {
switch (contextType) {
case 'shared-context': return path.join(this.contextPath, 'shared-context.md');
case 'decisions': return path.join(this.decisionsPath, 'decisions-log.md');
case 'progress': return path.join(this.progressPath, 'progress-summary.md');
case 'quality': return path.join(this.qualityPath, 'quality-metrics.md');
default: return path.join(this.contextPath, `${contextType}.md`);
}
}
async getRecentVersions(contextType, limit = 10) {
try {
const versionFiles = fs.readdirSync(this.versionsPath)
.filter(file => file.startsWith(contextType) && file.endsWith('.json'))
.map(file => {
const filePath = path.join(this.versionsPath, file);
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return content;
})
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.slice(0, limit);
return versionFiles;
} catch (error) {
console.error('Failed to get recent versions:', error.message);
return [];
}
}
async mergeContextChanges(contextType, baseContent, changes1, changes2) {
try {
// Simple merge strategy - combine unique elements
switch (contextType) {
case 'shared-context':
return this.mergeSharedContext(baseContent, changes1, changes2);
case 'decisions':
return this.mergeDecisions(baseContent, changes1, changes2);
case 'progress':
return this.mergeProgress(baseContent, changes1, changes2);
default:
// Default merge - prefer more recent changes
return changes2.timestamp > changes1.timestamp ? changes2.content : changes1.content;
}
} catch (error) {
console.error('Failed to merge context changes:', error.message);
throw error;
}
}
mergeSharedContext(base, changes1, changes2) {
try {
const baseCtx = this.parseSharedContext(base);
const ctx1 = this.parseSharedContext(changes1.content);
const ctx2 = this.parseSharedContext(changes2.content);
// Merge strategy: combine unique items, prefer most recent timestamps
const merged = {
lastUpdated: new Date().toISOString(),
activeSessions: [...new Set([...ctx1.activeSessions, ...ctx2.activeSessions])],
primaryAgent: ctx2.lastUpdated > ctx1.lastUpdated ? ctx2.primaryAgent : ctx1.primaryAgent,
currentFocus: ctx2.lastUpdated > ctx1.lastUpdated ? ctx2.currentFocus : ctx1.currentFocus,
keyDecisions: [...new Set([...ctx1.keyDecisions, ...ctx2.keyDecisions])],
nextSteps: [...new Set([...ctx1.nextSteps, ...ctx2.nextSteps])],
sessionNotes: `${ctx1.sessionNotes}\n${ctx2.sessionNotes}`.trim()
};
return this.formatSharedContext(merged);
} catch (error) {
console.error('Failed to merge shared context:', error.message);
return changes2.content; // Fallback to most recent
}
}
mergeDecisions(base, changes1, changes2) {
try {
// Decisions are append-only, so merge by combining unique decisions
const decisions1 = this.parseDecisions(changes1.content);
const decisions2 = this.parseDecisions(changes2.content);
const allDecisions = [...decisions1, ...decisions2];
const uniqueDecisions = allDecisions.filter((decision, index, self) =>
index === self.findIndex(d => d.title === decision.title)
);
// Sort by date
uniqueDecisions.sort((a, b) => new Date(a.date) - new Date(b.date));
let mergedContent = '# Architectural & Design Decisions\n\n';
uniqueDecisions.forEach((decision, index) => {
const decisionId = String(index + 1).padStart(3, '0');
mergedContent += `## Decision ${decisionId}: ${decision.title}
**Date:** ${decision.date}
**Agent:** ${decision.agent}
**Context:** ${decision.context}
**Decision:** ${decision.decision}
**Rationale:** ${decision.rationale}
**Alternatives:** ${decision.alternatives}
**Impact:** ${decision.impact}
**Status:** ${decision.status}
`;
});
return mergedContent;
} catch (error) {
console.error('Failed to merge decisions:', error.message);
return changes2.content; // Fallback to most recent
}
}
mergeProgress(base, changes1, changes2) {
try {
const progress1 = this.parseProgress(changes1.content);
const progress2 = this.parseProgress(changes2.content);
// Merge strategy: combine tasks, keep most recent story and quality score
const merged = {
lastUpdated: new Date().toISOString(),
currentStory: progress2.lastUpdated > progress1.lastUpdated ? progress2.currentStory : progress1.currentStory,
completedTasks: [...new Set([...progress1.completedTasks, ...progress2.completedTasks])],
pendingTasks: [...new Set([...progress1.pendingTasks, ...progress2.pendingTasks])],
blockers: [...new Set([...progress1.blockers, ...progress2.blockers])],
qualityScore: progress2.lastUpdated > progress1.lastUpdated ? progress2.qualityScore : progress1.qualityScore
};
return this.formatProgress(merged);
} catch (error) {
console.error('Failed to merge progress:', error.message);
return changes2.content; // Fallback to most recent
}
}
async rollbackToVersion(contextType, versionId) {
try {
const versionFile = path.join(this.versionsPath, `${versionId}.json`);
if (!fs.existsSync(versionFile)) {
throw new Error(`Version ${versionId} not found`);
}
const version = JSON.parse(fs.readFileSync(versionFile, 'utf8'));
const contextFile = this.getContextFilePath(contextType);
// Create backup of current version
await this.createContextVersion(contextType, fs.readFileSync(contextFile, 'utf8'), 'system', 'rollback-backup');
// Restore the version
fs.writeFileSync(contextFile, version.content);
return {
success: true,
rolledBackTo: version.timestamp,
agent: version.agent,
sessionId: version.sessionId
};
} catch (error) {
console.error('Failed to rollback to version:', error.message);
throw error;
}
}
// CONTEXT LOCKING FOR CONCURRENT ACCESS
async acquireContextLock(contextType, sessionId, timeout = 30000) {
try {
const lockFile = path.join(this.locksPath, `${contextType}.lock`);
const lockInfo = {
sessionId,
timestamp: new Date().toISOString(),
expires: new Date(Date.now() + timeout).toISOString()
};
// Check for existing lock
if (fs.existsSync(lockFile)) {
const existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
// Check if lock has expired
if (new Date(existingLock.expires) > new Date()) {
if (existingLock.sessionId !== sessionId) {
return { acquired: false, lockedBy: existingLock.sessionId, expiresAt: existingLock.expires };
}
// Same session, extend the lock
lockInfo.timestamp = existingLock.timestamp;
}
}
fs.writeFileSync(lockFile, JSON.stringify(lockInfo, null, 2));
return { acquired: true, lockInfo };
} catch (error) {
console.error('Failed to acquire context lock:', error.message);
return { acquired: false, error: error.message };
}
}
async releaseContextLock(contextType, sessionId) {
try {
const lockFile = path.join(this.locksPath, `${contextType}.lock`);
if (!fs.existsSync(lockFile)) {
return { released: true, message: 'No lock existed' };
}
const existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
if (existingLock.sessionId !== sessionId) {
return { released: false, message: 'Lock owned by different session' };
}
fs.unlinkSync(lockFile);
return { released: true, message: 'Lock released successfully' };
} catch (error) {
console.error('Failed to release context lock:', error.message);
return { released: false, error: error.message };
}
}
async cleanupExpiredLocks() {
try {
const lockFiles = fs.readdirSync(this.locksPath).filter(f => f.endsWith('.lock'));
let cleanedCount = 0;
for (const lockFile of lockFiles) {
const lockPath = path.join(this.locksPath, lockFile);
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
if (new Date(lock.expires) < new Date()) {
fs.unlinkSync(lockPath);
cleanedCount++;
}
}
return { cleanedCount };
} catch (error) {
console.error('Failed to cleanup expired locks:', error.message);
return { cleanedCount: 0, error: error.message };
}
}
// BMAD AGENT INTEGRATION HOOKS
async onStoryStart(storyId, agent, sessionId) {
try {
// Automatically update context when a story starts
await this.updateSharedContext({
currentFocus: `Story ${storyId} started by ${agent}`,
primaryAgent: agent
});
// Log story start as a progress update
await this.updateProgress({
currentStory: storyId,
lastUpdated: new Date().toISOString()
});
console.log(`✅ Context updated for story start: ${storyId} by ${agent}`);
} catch (error) {
console.error('Failed to handle story start:', error.message);
}
}
async onDecisionMade(decision, agent, sessionId) {
try {
// Automatically log decisions made during agent operations
await this.logDecision({
title: decision.title,
agent: agent,
context: decision.context || 'Auto-captured during agent operation',
decision: decision.decision,
rationale: decision.rationale || 'No rationale provided',
alternatives: decision.alternatives || 'No alternatives considered',
impact: decision.impact || 'Impact not assessed',
status: decision.status || 'active'
});
console.log(`✅ Decision auto-logged: ${decision.title} by ${agent}`);
} catch (error) {
console.error('Failed to handle decision made:', error.message);
}
}
async onQualityAudit(results, agent, sessionId) {
try {
// Automatically capture quality audit results
await this.updateQualityMetrics({
agent: agent,
story: results.story || 'Unknown story',
realityAuditScore: results.realityAuditScore || results.score,
patternCompliance: results.patternCompliance,
technicalDebtScore: results.technicalDebtScore,
overallQuality: results.overallQuality || results.grade,
details: results.details || 'Auto-captured quality audit results'
});
console.log(`✅ Quality metrics auto-captured: ${results.overallQuality || results.grade} by ${agent}`);
} catch (error) {
console.error('Failed to handle quality audit:', error.message);
}
}
async onAgentHandoff(fromAgent, toAgent, sessionId, handoffContext) {
try {
// Automatically update context during agent handoffs
await this.updateSharedContext({
primaryAgent: toAgent,
sessionNotes: `Agent handoff: ${fromAgent}${toAgent} at ${new Date().toISOString()}`
});
// Log handoff as a decision if it includes important context
if (handoffContext && handoffContext.length > 50) {
await this.logDecision({
title: `Agent handoff: ${fromAgent} to ${toAgent}`,
agent: fromAgent,
context: 'Agent transition',
decision: `Handed off work context to ${toAgent}`,
rationale: handoffContext,
impact: `${toAgent} can continue work with full context`,
status: 'active'
});
}
console.log(`✅ Context updated for agent handoff: ${fromAgent}${toAgent}`);
} catch (error) {
console.error('Failed to handle agent handoff:', error.message);
}
}
}
module.exports = ContextManager;