UNPKG

@pimzino/claude-code-spec-workflow

Version:

Automated workflows for Claude Code. Includes spec-driven development (Requirements → Design → Tasks → Implementation) with intelligent orchestration, optional steering documents and streamlined bug fix workflow (Report → Analyze → Fix → Verify). We have

447 lines 20 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MultiProjectDashboardServer = void 0; const fastify_1 = __importDefault(require("fastify")); const static_1 = __importDefault(require("@fastify/static")); const websocket_1 = __importDefault(require("@fastify/websocket")); const path_1 = require("path"); const promises_1 = require("fs/promises"); const watcher_1 = require("./watcher"); const parser_1 = require("./parser"); const project_discovery_1 = require("./project-discovery"); const open_1 = __importDefault(require("open")); const os_1 = require("os"); const utils_1 = require("../utils"); const logger_1 = require("./logger"); class MultiProjectDashboardServer { constructor(options) { this.clients = new Set(); this.projects = new Map(); this.options = options; this.discovery = new project_discovery_1.ProjectDiscovery(); this.app = (0, fastify_1.default)({ logger: false }); } async start() { // Discover projects console.log('Starting project discovery...'); const discoveredProjects = await this.discovery.discoverProjects(); // Filter to show projects with specs OR active Claude sessions const activeProjects = discoveredProjects.filter((p) => (p.specCount || 0) > 0 || p.hasActiveSession); console.log(`Found ${activeProjects.map(p => p.name).join(', ')}`); // Initialize watchers for each project for (const project of activeProjects) { (0, logger_1.debug)(`Initializing project ${project.name}`); await this.initializeProject(project); } // Register plugins await this.app.register(static_1.default, { root: (0, path_1.join)(__dirname, 'public'), prefix: '/public/', }); // Serve multi.html as the main page this.app.get('/', async (request, reply) => { return reply.sendFile('multi.html', (0, path_1.join)(__dirname, 'public')); }); await this.app.register(websocket_1.default); // WebSocket endpoint const self = this; this.app.register(async function (fastify) { fastify.get('/ws', { websocket: true }, (connection) => { const socket = connection.socket; (0, logger_1.debug)('Multi-project WebSocket client connected'); self.clients.add(socket); // Send initial state with all projects self.sendInitialState(socket); socket.on('close', () => { self.clients.delete(socket); }); socket.on('error', (error) => { console.error('WebSocket error:', error); self.clients.delete(socket); }); }); }); // API endpoints this.app.get('/api/projects', async () => { const projectList = Array.from(this.projects.values()).map((p) => ({ ...p.project, specs: [], // Don't include specs in list })); return projectList; }); this.app.get('/api/projects/:projectPath/specs', async (request, reply) => { const { projectPath } = request.params; const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath))); const projectState = this.projects.get(decodedPath); if (!projectState) { reply.code(404).send({ error: 'Project not found' }); return; } const specs = await projectState.parser.getAllSpecs(); return specs; }); this.app.get('/api/projects/:projectPath/bugs', async (request, reply) => { const { projectPath } = request.params; const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath))); const projectState = this.projects.get(decodedPath); if (!projectState) { reply.code(404).send({ error: 'Project not found' }); return; } const bugs = await projectState.parser.getAllBugs(); return bugs; }); // Get raw markdown content for a specific document this.app.get('/api/projects/:projectPath/specs/:name/:document', async (request, reply) => { const { projectPath, name, document } = request.params; const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath))); const projectState = this.projects.get(decodedPath); if (!projectState) { reply.code(404).send({ error: 'Project not found' }); return; } const allowedDocs = ['requirements', 'design', 'tasks']; if (!allowedDocs.includes(document)) { reply.code(400).send({ error: 'Invalid document type' }); return; } const docPath = (0, path_1.join)(decodedPath, '.claude', 'specs', name, `${document}.md`); try { const content = await (0, promises_1.readFile)(docPath, 'utf-8'); return { content }; } catch { reply.code(404).send({ error: 'Document not found' }); } }); // Get raw markdown content for bug documents this.app.get('/api/projects/:projectPath/bugs/:name/:document', async (request, reply) => { const { projectPath, name, document } = request.params; const decodedPath = (0, path_1.normalize)((0, path_1.resolve)(decodeURIComponent(projectPath))); const projectState = this.projects.get(decodedPath); if (!projectState) { reply.code(404).send({ error: 'Project not found' }); return; } const allowedDocs = ['report', 'analysis', 'verification']; if (!allowedDocs.includes(document)) { reply.code(400).send({ error: 'Invalid document type' }); return; } const docPath = (0, path_1.join)(decodedPath, '.claude', 'bugs', name, `${document}.md`); try { const content = await (0, promises_1.readFile)(docPath, 'utf-8'); return { content }; } catch { reply.code(404).send({ error: 'Document not found' }); } }); // Find available port if the requested port is busy let actualPort = this.options.port; if (!(await (0, utils_1.isPortAvailable)(this.options.port))) { console.log(`Port ${this.options.port} is in use, finding alternative...`); actualPort = await (0, utils_1.findAvailablePort)(this.options.port); console.log(`Using port ${actualPort} instead`); } // Start server await this.app.listen({ port: actualPort, host: '0.0.0.0' }); // Update the port in options for URL generation this.options.port = actualPort; // Start periodic rescan for new active projects // Disabled: We now use file watching instead of polling // this.startPeriodicRescan(); // Open browser if requested if (this.options.autoOpen) { await (0, open_1.default)(`http://localhost:${this.options.port}`); } } async initializeProject(project) { // Normalize and resolve the project path to handle different path formats const normalizedPath = (0, path_1.normalize)((0, path_1.resolve)(project.path)); project.path = normalizedPath; // Update the project path with normalized version const parser = new parser_1.SpecParser(normalizedPath); const watcher = new watcher_1.SpecWatcher(normalizedPath, parser); // Set up watcher events watcher.on('change', async (event) => { // Transform the watcher event into the format expected by the client const projectUpdateEvent = { type: 'spec-update', spec: event.spec, file: event.file, data: event.data, }; // Broadcast update with project context const message = JSON.stringify({ type: 'project-update', projectPath: project.path, data: projectUpdateEvent, }); (0, logger_1.debug)(`[Multi-server] Sending spec update for ${project.name}/${event.spec} to ${this.clients.size} clients`); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); // Also send updated active tasks const activeTasks = await this.collectActiveTasks(); (0, logger_1.debug)(`[Multi-server] Collected ${activeTasks.length} active tasks after spec update`); const activeTasksMessage = JSON.stringify({ type: 'active-tasks-update', data: activeTasks, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(activeTasksMessage); } }); }); // Handle git change events watcher.on('git-change', async (event) => { (0, logger_1.debug)(`[Multi-server] Git change detected for ${project.name}:`, event); // Update the project's git info if (event.branch) { project.gitBranch = event.branch; } if (event.commit) { project.gitCommit = event.commit; } // Send git update to all clients const message = JSON.stringify({ type: 'git-update', projectPath: project.path, gitBranch: project.gitBranch, gitCommit: project.gitCommit, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); }); // Handle steering change events watcher.on('steering-change', async (event) => { (0, logger_1.debug)(`[Multi-server] Steering change detected for ${project.name}:`, event); // Send steering update to all clients const message = JSON.stringify({ type: 'steering-update', projectPath: project.path, data: event.steeringStatus, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); }); // Handle bug change events watcher.on('bug-change', async (event) => { (0, logger_1.debug)(`[Multi-server] Bug change detected for ${project.name}:`, event); // Send bug update to all clients const message = JSON.stringify({ type: 'bug-update', projectPath: project.path, data: event, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); }); await watcher.start(); this.projects.set(project.path, { project, parser, watcher, }); } async sendInitialState(socket) { const projects = await Promise.all(Array.from(this.projects.entries()).map(async ([, state]) => { const specs = await state.parser.getAllSpecs(); const bugs = await state.parser.getAllBugs(); const steeringStatus = await state.parser.getProjectSteeringStatus(); const projectData = { ...state.project, specs, bugs, steering: steeringStatus, }; (0, logger_1.debug)(`Sending project ${projectData.name}`); return projectData; })); // Collect all active tasks across projects const activeTasks = await this.collectActiveTasks(); socket.send(JSON.stringify({ type: 'initial', data: projects, activeTasks, username: this.getUsername(), })); } async collectActiveTasks() { const activeTasks = []; for (const [projectPath, state] of this.projects) { const specs = await state.parser.getAllSpecs(); (0, logger_1.debug)(`[collectActiveTasks] Project ${state.project.name}: ${specs.length} specs`); let hasActiveTaskInProject = false; for (const spec of specs) { if (spec.tasks && spec.tasks.taskList.length > 0) { (0, logger_1.debug)(`[collectActiveTasks] Spec ${spec.name}: ${spec.tasks.taskList.length} tasks, inProgress: ${spec.tasks.inProgress}`); if (spec.tasks.inProgress) { // Only get the currently active task (marked as in progress) const activeTask = this.findTaskById(spec.tasks.taskList, spec.tasks.inProgress); if (activeTask) { hasActiveTaskInProject = true; activeTasks.push({ projectPath, projectName: state.project.name, specName: spec.name, specDisplayName: spec.displayName, task: activeTask, isCurrentlyActive: true, hasActiveSession: true, gitBranch: state.project.gitBranch, gitCommit: state.project.gitCommit, }); } } } } // Update the project's active session status based on whether it has active tasks if (state.project.hasActiveSession !== hasActiveTaskInProject) { state.project.hasActiveSession = hasActiveTaskInProject; // Send status update to clients const statusUpdate = { type: 'project-update', projectPath, data: { type: 'status-update', data: { hasActiveSession: hasActiveTaskInProject, lastActivity: new Date(), isClaudeActive: hasActiveTaskInProject } } }; this.clients.forEach((client) => { if (client.readyState === 1) { client.send(JSON.stringify(statusUpdate)); } }); } } // Sort by currently active first, then by project name activeTasks.sort((a, b) => { if (a.isCurrentlyActive && !b.isCurrentlyActive) return -1; if (!a.isCurrentlyActive && b.isCurrentlyActive) return 1; return a.projectName.localeCompare(b.projectName); }); (0, logger_1.debug)(`[collectActiveTasks] Total active tasks found: ${activeTasks.length}`); return activeTasks; } findTaskById(tasks, taskId) { for (const task of tasks) { if (task.id === taskId) { return task; } if (task.subtasks) { const found = this.findTaskById(task.subtasks, taskId); if (found) { return found; } } } return null; } startPeriodicRescan() { // Rescan every 30 seconds for new active projects this.rescanInterval = setInterval(async () => { const currentProjects = await this.discovery.discoverProjects(); const activeProjects = currentProjects.filter((p) => (p.specCount || 0) > 0 || p.hasActiveSession); // Check for new projects for (const project of activeProjects) { if (!this.projects.has(project.path)) { (0, logger_1.debug)(`New active project detected: ${project.name}`); await this.initializeProject(project); // Notify all clients about the new project const parser = this.projects.get(project.path)?.parser; const specs = (await parser?.getAllSpecs()) || []; const bugs = (await parser?.getAllBugs()) || []; const projectData = { ...project, specs, bugs }; const message = JSON.stringify({ type: 'new-project', data: projectData, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); } } // Check for projects that are no longer active for (const [path, state] of this.projects) { const stillActive = activeProjects.some((p) => p.path === path); if (!stillActive) { const hasSpecs = (await state.parser.getAllSpecs()).length > 0; const currentProject = activeProjects.find((p) => p.path === path); const hasActiveSession = currentProject?.hasActiveSession || false; if (!hasSpecs && !hasActiveSession) { (0, logger_1.debug)(`Project no longer active: ${state.project.name}`); await state.watcher.stop(); this.projects.delete(path); // Notify clients to remove the project const message = JSON.stringify({ type: 'remove-project', data: { path }, }); this.clients.forEach((client) => { if (client.readyState === 1) { client.send(message); } }); } } } }, 30000); // 30 seconds } async stop() { // Stop the rescan interval if (this.rescanInterval) { clearInterval(this.rescanInterval); } // Stop all watchers for (const [, state] of this.projects) { await state.watcher.stop(); } // Close all WebSocket connections this.clients.forEach((client) => { if (client.readyState === 1) { client.close(); } }); this.clients.clear(); // Close the server await this.app.close(); } getUsername() { try { const info = (0, os_1.userInfo)(); // Try to get the full name first, fall back to username const username = info.username || 'User'; // Capitalize first letter return username.charAt(0).toUpperCase() + username.slice(1); } catch { return 'User'; } } } exports.MultiProjectDashboardServer = MultiProjectDashboardServer; //# sourceMappingURL=multi-server.js.map