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