UNPKG

bmad-method-mcp

Version:

Breakthrough Method of Agile AI-driven Development with Enhanced MCP Integration

616 lines (522 loc) 18.3 kB
const { Database } = require('sqlite3'); const path = require('path'); const fs = require('fs-extra'); const { v4: uuidv4 } = require('uuid'); /** * SQLite storage adapter for BMAD MCP Server * Handles project data persistence and retrieval */ class BMadStorage { constructor() { this.db = null; this.dbPath = null; } async initialize() { // Find project database or create one this.dbPath = await this.findOrCreateDatabase(); this.db = new Database(this.dbPath); // Enable foreign keys and WAL mode for better concurrency await this.run('PRAGMA foreign_keys = ON'); await this.run('PRAGMA journal_mode = WAL'); await this.setupTables(); } async findOrCreateDatabase() { // Look for existing .bmad/project.db in current directory tree let currentDir = process.cwd(); while (currentDir !== path.dirname(currentDir)) { const dbPath = path.join(currentDir, '.bmad', 'project.db'); if (await fs.pathExists(dbPath)) { return dbPath; } currentDir = path.dirname(currentDir); } // Create new database in current directory const bmadDir = path.join(process.cwd(), '.bmad'); await fs.ensureDir(bmadDir); const dbPath = path.join(bmadDir, 'project.db'); return dbPath; } async setupTables() { const tables = [ // Project metadata `CREATE TABLE IF NOT EXISTS project_meta ( key TEXT PRIMARY KEY, value TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Documents (PRDs, Architecture, etc.) `CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT, status TEXT DEFAULT 'DRAFT', version INTEGER DEFAULT 1, metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Epics `CREATE TABLE IF NOT EXISTS epics ( id TEXT PRIMARY KEY, epic_num INTEGER NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT, status TEXT DEFAULT 'TODO', priority TEXT DEFAULT 'MEDIUM', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Sprints `CREATE TABLE IF NOT EXISTS sprints ( id TEXT PRIMARY KEY, name TEXT NOT NULL, goal TEXT, start_date DATE, end_date DATE, status TEXT DEFAULT 'PLANNING', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Tasks/Stories `CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, epic_id TEXT, sprint_id TEXT, epic_num INTEGER, story_num INTEGER, title TEXT NOT NULL, description TEXT, status TEXT DEFAULT 'TODO', assignee TEXT, priority TEXT DEFAULT 'MEDIUM', estimated_hours INTEGER, actual_hours INTEGER, metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (epic_id) REFERENCES epics(id), FOREIGN KEY (sprint_id) REFERENCES sprints(id) )`, // Document links for many-to-many relationships `CREATE TABLE IF NOT EXISTS document_links ( id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, document_id TEXT NOT NULL, document_section TEXT, link_purpose TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (document_id) REFERENCES documents(id), UNIQUE(entity_type, entity_id, document_id, document_section) )`, // Change tracking `CREATE TABLE IF NOT EXISTS changes ( id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, field_name TEXT, old_value TEXT, new_value TEXT, changed_by TEXT, changed_at DATETIME DEFAULT CURRENT_TIMESTAMP )` ]; for (const table of tables) { await this.run(table); } // Create indexes const indexes = [ 'CREATE INDEX IF NOT EXISTS idx_tasks_epic_story ON tasks(epic_num, story_num)', 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', 'CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)', 'CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(type)', 'CREATE INDEX IF NOT EXISTS idx_document_links_entity ON document_links(entity_type, entity_id)', 'CREATE INDEX IF NOT EXISTS idx_document_links_doc ON document_links(document_id)', 'CREATE INDEX IF NOT EXISTS idx_document_links_section ON document_links(document_id, document_section)', 'CREATE INDEX IF NOT EXISTS idx_changes_entity ON changes(entity_type, entity_id)', 'CREATE INDEX IF NOT EXISTS idx_epics_num ON epics(epic_num)' ]; for (const index of indexes) { await this.run(index); } // Initialize project if not exists await this.initializeProject(); } async initializeProject() { const existing = await this.get('SELECT value FROM project_meta WHERE key = ?', ['name']); if (!existing) { const projectName = path.basename(process.cwd()); await this.setProjectMeta('id', uuidv4()); await this.setProjectMeta('name', projectName); await this.setProjectMeta('description', 'BMAD Project'); await this.setProjectMeta('created_at', new Date().toISOString()); } } // Project metadata operations async getProjectMeta(key) { const result = await this.get('SELECT value FROM project_meta WHERE key = ?', [key]); return result?.value; } async setProjectMeta(key, value) { await this.run( 'INSERT OR REPLACE INTO project_meta (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)', [key, value] ); } async getProject() { const rows = await this.all('SELECT key, value FROM project_meta'); const project = {}; rows.forEach(row => project[row.key] = row.value); return project; } // Document operations async createDocument(type, title, content, status = 'DRAFT') { const id = uuidv4(); await this.run( 'INSERT INTO documents (id, type, title, content, status) VALUES (?, ?, ?, ?, ?)', [id, type, title, content, status] ); return { id, type, title, status }; } async getDocument(id) { return await this.get('SELECT * FROM documents WHERE id = ?', [id]); } async listDocuments(type = null) { if (type) { return await this.all('SELECT * FROM documents WHERE type = ? ORDER BY updated_at DESC', [type]); } return await this.all('SELECT * FROM documents ORDER BY updated_at DESC'); } async updateDocument(id, updates) { const fields = []; const values = []; Object.entries(updates).forEach(([key, value]) => { fields.push(`${key} = ?`); values.push(value); }); if (fields.length > 0) { fields.push('updated_at = CURRENT_TIMESTAMP'); values.push(id); await this.run( `UPDATE documents SET ${fields.join(', ')} WHERE id = ?`, values ); } } // Epic operations async createEpic(epicNum, title, description) { const id = uuidv4(); await this.run( 'INSERT INTO epics (id, epic_num, title, description) VALUES (?, ?, ?, ?)', [id, epicNum, title, description] ); return { id, epicNum, title, description }; } async getEpic(epicNum) { return await this.get('SELECT * FROM epics WHERE epic_num = ?', [epicNum]); } async listEpics() { return await this.all('SELECT * FROM epics ORDER BY epic_num'); } // Task operations async createTask(epicNum, storyNum, title, description, assignee = null) { const id = uuidv4(); // Find epic by number const epic = await this.get('SELECT id FROM epics WHERE epic_num = ?', [epicNum]); const epicId = epic?.id; await this.run( 'INSERT INTO tasks (id, epic_id, epic_num, story_num, title, description, assignee) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, epicId, epicNum, storyNum, title, description, assignee] ); return { id, epicNum, storyNum, title, description, assignee }; } async getTask(id) { return await this.get('SELECT * FROM tasks WHERE id = ?', [id]); } async updateTask(id, updates) { const fields = []; const values = []; Object.entries(updates).forEach(([key, value]) => { fields.push(`${key} = ?`); values.push(value); }); if (fields.length > 0) { fields.push('updated_at = CURRENT_TIMESTAMP'); values.push(id); await this.run( `UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`, values ); } } async queryTasks(filters = {}) { let query = 'SELECT * FROM tasks WHERE 1=1'; const params = []; if (filters.status) { if (Array.isArray(filters.status)) { const placeholders = filters.status.map(() => '?').join(','); query += ` AND status IN (${placeholders})`; params.push(...filters.status); } else { query += ' AND status = ?'; params.push(filters.status); } } if (filters.epicNum) { query += ' AND epic_num = ?'; params.push(filters.epicNum); } if (filters.assignee) { query += ' AND assignee = ?'; params.push(filters.assignee); } if (filters.sprintId) { query += ' AND sprint_id = ?'; params.push(filters.sprintId); } query += ' ORDER BY epic_num, story_num'; return await this.all(query, params); } async getNextStoryNum(epicNum) { const result = await this.get( 'SELECT MAX(story_num) as max_story FROM tasks WHERE epic_num = ?', [epicNum] ); return (result?.max_story || 0) + 1; } // Sprint operations async createSprint(name, goal, startDate = null, endDate = null) { const id = uuidv4(); await this.run( 'INSERT INTO sprints (id, name, goal, start_date, end_date) VALUES (?, ?, ?, ?, ?)', [id, name, goal, startDate, endDate] ); return { id, name, goal, startDate, endDate }; } async getActiveSprint() { return await this.get('SELECT * FROM sprints WHERE status = ? ORDER BY created_at DESC LIMIT 1', ['ACTIVE']); } async listSprints() { return await this.all('SELECT * FROM sprints ORDER BY created_at DESC'); } async querySprints(filters = {}) { let query = 'SELECT * FROM sprints WHERE 1=1'; const params = []; if (filters.status) { if (Array.isArray(filters.status)) { const placeholders = filters.status.map(() => '?').join(','); query += ` AND status IN (${placeholders})`; params.push(...filters.status); } else { query += ' AND status = ?'; params.push(filters.status); } } if (filters.name) { query += ' AND name LIKE ?'; params.push(`%${filters.name}%`); } query += ' ORDER BY created_at DESC'; return await this.all(query, params); } // Progress and analytics async getProjectProgress() { const total = await this.get('SELECT COUNT(*) as count FROM tasks'); const byStatus = await this.all(` SELECT status, COUNT(*) as count FROM tasks GROUP BY status `); const byEpic = await this.all(` SELECT epic_num, COUNT(*) as count FROM tasks WHERE epic_num IS NOT NULL GROUP BY epic_num ORDER BY epic_num `); return { totalTasks: total.count, byStatus: byStatus.reduce((acc, row) => { acc[row.status] = row.count; return acc; }, {}), byEpic: byEpic.reduce((acc, row) => { acc[row.epic_num] = row.count; return acc; }, {}) }; } // Document section operations (parse on-the-fly) parseDocumentSections(content) { const sections = []; const lines = content.split('\n'); let currentSection = null; let sectionOrder = 0; let lineIndex = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headerMatch) { const level = headerMatch[1].length; const title = headerMatch[2]; const sectionId = title.toLowerCase() .replace(/[^a-z0-9\s]/g, '') .replace(/\s+/g, '-'); // Save previous section if exists if (currentSection) { currentSection.content = currentSection.content.trim(); sections.push(currentSection); } // Start new section currentSection = { section_id: sectionId, section_title: title, section_level: level, section_order: sectionOrder++, start_line: i, content: '', parent_section_id: null }; // Find parent section (previous section with lower level) for (let j = sections.length - 1; j >= 0; j--) { if (sections[j].section_level < level) { currentSection.parent_section_id = sections[j].section_id; break; } } } else if (currentSection) { // Add content to current section currentSection.content += line + '\n'; } } // Save last section if (currentSection) { currentSection.content = currentSection.content.trim(); sections.push(currentSection); } return sections; } async getDocumentBySection(documentId, sectionId) { const document = await this.get('SELECT * FROM documents WHERE id = ?', [documentId]); if (!document || !document.content) { return null; } const sections = this.parseDocumentSections(document.content); const section = sections.find(s => s.section_id === sectionId); if (!section) { return null; } return { ...document, focused_section: section, section_content: section.content, sections: sections }; } async getDocumentSections(documentId) { const document = await this.get('SELECT * FROM documents WHERE id = ?', [documentId]); if (!document || !document.content) { return []; } return this.parseDocumentSections(document.content); } async linkEntityToDocument(entityType, entityId, documentId, documentSection, linkPurpose = null) { const { v4: uuidv4 } = require('uuid'); const id = uuidv4(); await this.run( `INSERT OR REPLACE INTO document_links (id, entity_type, entity_id, document_id, document_section, link_purpose) VALUES (?, ?, ?, ?, ?, ?)`, [id, entityType, entityId, documentId, documentSection, linkPurpose] ); return { id, entity_type: entityType, entity_id: entityId, document_id: documentId, document_section: documentSection }; } async unlinkEntityFromDocument(entityType, entityId, documentId, documentSection = null) { const whereClause = documentSection ? 'entity_type = ? AND entity_id = ? AND document_id = ? AND document_section = ?' : 'entity_type = ? AND entity_id = ? AND document_id = ?'; const params = documentSection ? [entityType, entityId, documentId, documentSection] : [entityType, entityId, documentId]; await this.run(`DELETE FROM document_links WHERE ${whereClause}`, params); } async getEntityDocumentLinks(entityType, entityId) { return await this.all( `SELECT dl.*, d.title, d.type FROM document_links dl JOIN documents d ON dl.document_id = d.id WHERE dl.entity_type = ? AND dl.entity_id = ?`, [entityType, entityId] ); } async getEntitiesLinkedToDocument(documentId, sectionId = null) { const whereClause = sectionId ? 'dl.document_id = ? AND dl.document_section = ?' : 'dl.document_id = ?'; const params = sectionId ? [documentId, sectionId] : [documentId]; const entities = { tasks: [], epics: [], sprints: [] }; // Get tasks with document links entities.tasks = await this.all(` SELECT t.*, dl.document_section, dl.link_purpose, dl.created_at as linked_at FROM tasks t JOIN document_links dl ON dl.entity_type = 'task' AND dl.entity_id = t.id WHERE ${whereClause} `, params); // Get epics with document links entities.epics = await this.all(` SELECT e.*, dl.document_section, dl.link_purpose, dl.created_at as linked_at FROM epics e JOIN document_links dl ON dl.entity_type = 'epic' AND dl.entity_id = e.id WHERE ${whereClause} `, params); // Get sprints with document links entities.sprints = await this.all(` SELECT s.*, dl.document_section, dl.link_purpose, dl.created_at as linked_at FROM sprints s JOIN document_links dl ON dl.entity_type = 'sprint' AND dl.entity_id = s.id WHERE ${whereClause} `, params); return entities; } // Database helpers async run(query, params = []) { return new Promise((resolve, reject) => { this.db.run(query, params, function(err) { if (err) reject(err); else resolve({ changes: this.changes, lastID: this.lastID }); }); }); } async get(query, params = []) { return new Promise((resolve, reject) => { this.db.get(query, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } async all(query, params = []) { return new Promise((resolve, reject) => { this.db.all(query, params, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); } async close() { if (this.db) { await new Promise((resolve, reject) => { this.db.close((err) => { if (err) reject(err); else resolve(); }); }); } } } module.exports = BMadStorage;