UNPKG

@pimzino/claude-code-spec-workflow

Version:

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

295 lines 13.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ProjectDiscovery = void 0; const fs_1 = require("fs"); const path_1 = require("path"); const os_1 = require("os"); const child_process_1 = require("child_process"); const util_1 = require("util"); const simple_git_1 = require("simple-git"); const logger_1 = require("./logger"); const execAsync = (0, util_1.promisify)(child_process_1.exec); class ProjectDiscovery { constructor() { this.searchPaths = []; // Common project directories this.searchPaths = [ (0, path_1.join)((0, os_1.homedir)(), 'Projects'), (0, path_1.join)((0, os_1.homedir)(), 'Documents'), (0, path_1.join)((0, os_1.homedir)(), 'Development'), (0, path_1.join)((0, os_1.homedir)(), 'Code'), (0, path_1.join)((0, os_1.homedir)(), 'repos'), (0, path_1.join)((0, os_1.homedir)(), 'workspace'), (0, path_1.join)((0, os_1.homedir)(), 'src'), ]; } async discoverProjects() { const allProjects = []; const activeClaudes = await this.getActiveClaudeSessions(); (0, logger_1.debug)(`Starting project discovery with ${activeClaudes.length} active Claude sessions`); // Search for .claude directories for (const searchPath of this.searchPaths) { try { await fs_1.promises.access(searchPath); (0, logger_1.debug)(`Searching in: ${searchPath}`); const found = await this.searchDirectory(searchPath, activeClaudes); (0, logger_1.debug)(`Found ${found.length} projects in ${searchPath}`); allProjects.push(...found); } catch { (0, logger_1.debug)(`Directory doesn't exist: ${searchPath}`); // Directory doesn't exist, skip it } } // Filter out projects that have a .claude directory but no specs or bugs // (unless they have an active session) const filteredProjects = allProjects.filter(project => { const hasContent = (project.specCount || 0) > 0 || (project.bugCount || 0) > 0; const keep = hasContent || project.hasActiveSession; if (!keep) { (0, logger_1.debug)(`Filtering out project ${project.name} at ${project.path} - no specs/bugs and no active session`); } return keep; }); // Sort by last activity filteredProjects.sort((a, b) => { const dateA = a.lastActivity?.getTime() || 0; const dateB = b.lastActivity?.getTime() || 0; return dateB - dateA; }); return filteredProjects; } groupRelatedProjects(projects) { // Create a map for quick lookup const projectMap = new Map(); projects.forEach(p => projectMap.set(p.path, p)); // Find parent-child relationships projects.forEach(project => { // Check if this project is nested inside another project const pathParts = project.path.split('/'); for (let i = pathParts.length - 1; i > 0; i--) { const potentialParentPath = pathParts.slice(0, i).join('/'); const parentProject = projectMap.get(potentialParentPath); if (parentProject) { // This is a child project project.parentPath = parentProject.path; if (!parentProject.children) { parentProject.children = []; } parentProject.children.push(project); break; } } }); // Return only top-level projects (ones without parents) // Child projects will be included in their parent's children array return projects.filter(p => !p.parentPath); } async searchDirectory(dir, activeSessions, depth = 0) { if (depth > 4) return []; // Search up to 4 directories deep const projects = []; try { const entries = await fs_1.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('.') && entry.name !== '.claude') continue; if (entry.name === 'node_modules' || entry.name === 'venv' || entry.name === '__pycache__') continue; const fullPath = (0, path_1.join)(dir, entry.name); // Special debug for phenix paths if (fullPath.includes('phenix')) { (0, logger_1.debug)(`Checking phenix-related path: ${fullPath} (depth: ${depth})`); } // Check if this directory has a .claude folder const claudePath = (0, path_1.join)(fullPath, '.claude'); try { const claudeStat = await fs_1.promises.stat(claudePath); if (claudeStat.isDirectory()) { const project = await this.analyzeProject(fullPath, claudePath, activeSessions); projects.push(project); (0, logger_1.debug)(`Found project with .claude dir: ${fullPath}`); } } catch { // No .claude directory } // Always check subdirectories if we haven't reached max depth // This ensures we find nested projects like phenix/phenix/public-api if (depth < 4) { (0, logger_1.debug)(`Searching subdirectory: ${fullPath} (depth: ${depth})`); const subProjects = await this.searchDirectory(fullPath, activeSessions, depth + 1); if (subProjects.length > 0) { (0, logger_1.debug)(`Found ${subProjects.length} projects in subdirectory ${fullPath}`); } projects.push(...subProjects); } } } catch (error) { console.error(`Error searching directory ${dir}:`, error); } return projects; } async analyzeProject(projectPath, claudePath, activeSessions) { (0, logger_1.debug)(`Analyzing project: ${projectPath}`); // Just use the last segment of the path as the name const name = projectPath.split('/').pop() || 'Unknown'; // Check if any active Claude session is in this project directory const hasActiveSession = activeSessions.some((session) => { // Normalize paths for comparison const normalizedSession = session.replace(/\/$/, ''); const normalizedProject = projectPath.replace(/\/$/, ''); // Only match if the session is exactly this project, not a subdirectory // This prevents /Users/michael/Projects/phenix from matching /Users/michael/Projects/phenix/phenix/public-api const isMatch = normalizedSession === normalizedProject; if (isMatch) { (0, logger_1.debug)(`Found active session match: ${session} matches ${projectPath}`); } return isMatch; }); if (!hasActiveSession && name === 'beejax') { (0, logger_1.debug)(`No active session found for beejax. Active sessions: ${activeSessions.join(', ')}, Project path: ${projectPath}`); } // Get git info (do this outside the try block so it always runs) const gitInfo = await this.getGitInfo(projectPath); // Get last activity by checking file modification times let lastActivity; let specCount = 0; let bugCount = 0; try { const specsPath = (0, path_1.join)(claudePath, 'specs'); const specDirs = await fs_1.promises.readdir(specsPath); let mostRecent = 0; for (const specDir of specDirs) { if (specDir.startsWith('.')) continue; const specPath = (0, path_1.join)(specsPath, specDir); const stat = await fs_1.promises.stat(specPath); if (stat.mtime.getTime() > mostRecent) { mostRecent = stat.mtime.getTime(); } } if (mostRecent > 0) { lastActivity = new Date(mostRecent); } specCount = specDirs.filter((d) => !d.startsWith('.')).length; (0, logger_1.debug)(`Found ${specCount} specs in ${projectPath}`); } catch (error) { // Error reading specs directory, but continue with git info (0, logger_1.debug)(`Could not read specs for ${projectPath}: ${error}`); specCount = 0; } // Count bugs try { const bugsPath = (0, path_1.join)(claudePath, 'bugs'); const bugDirs = await fs_1.promises.readdir(bugsPath); bugCount = bugDirs.filter((d) => !d.startsWith('.')).length; // Check bug modification times for last activity for (const bugDir of bugDirs) { if (bugDir.startsWith('.')) continue; const bugPath = (0, path_1.join)(bugsPath, bugDir); try { const stat = await fs_1.promises.stat(bugPath); if (!lastActivity || stat.mtime > lastActivity) { lastActivity = stat.mtime; } } catch { // Error reading bug directory } } } catch (error) { // No bugs directory or error reading it (0, logger_1.debug)(`Could not read bugs for ${projectPath}: ${error}`); bugCount = 0; } const result = { path: projectPath, name, hasActiveSession, lastActivity, specCount, bugCount, ...gitInfo, }; (0, logger_1.debug)(`Returning project ${name} with result:`, { path: projectPath, specCount, bugCount, hasGitInfo: !!(result.gitBranch || result.gitCommit), gitBranch: result.gitBranch, gitCommit: result.gitCommit }); return result; } async getActiveClaudeSessions() { try { // Get Claude processes with their working directories const { stdout } = await execAsync('ps aux | grep "claude" | grep -v grep | grep -v claude-code-spec'); const lines = stdout .trim() .split('\n') .filter((line) => line.length > 0); // Try to get working directories for each Claude process const sessions = []; for (const line of lines) { const parts = line.split(/\s+/); const pid = parts[1]; try { // On macOS, we can try to get the current working directory const { stdout: cwd } = await execAsync(`lsof -p ${pid} | grep cwd | awk '{print $NF}'`); if (cwd.trim()) { sessions.push(cwd.trim()); } } catch { // Can't get CWD, that's okay } } (0, logger_1.debug)(`Found ${sessions.length} active Claude sessions:`, sessions); return sessions; } catch { return []; } } async getGitInfo(projectPath) { (0, logger_1.debug)(`Getting git info for: ${projectPath}`); try { const git = (0, simple_git_1.simpleGit)(projectPath); // Check if it's a git repository const isRepo = await git.checkIsRepo(); if (!isRepo) { (0, logger_1.debug)(`${projectPath} is not a git repository`); return {}; } // Get current branch const branchSummary = await git.branchLocal(); const branch = branchSummary.current; // Get current commit hash (short version) const log = await git.log({ maxCount: 1 }); const commit = log.latest?.hash.substring(0, 7); const gitInfo = { gitBranch: branch || undefined, gitCommit: commit || undefined, }; if (gitInfo.gitBranch || gitInfo.gitCommit) { (0, logger_1.debug)(`Git info for ${projectPath}: branch=${gitInfo.gitBranch}, commit=${gitInfo.gitCommit}`); } return gitInfo; } catch (error) { // Not a git repo or git command failed (0, logger_1.debug)(`Failed to get git info for ${projectPath}:`, error instanceof Error ? error.message : String(error)); return {}; } } } exports.ProjectDiscovery = ProjectDiscovery; //# sourceMappingURL=project-discovery.js.map