@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
JavaScript
"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