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

403 lines 18.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SpecWatcher = void 0; const chokidar_1 = require("chokidar"); const events_1 = require("events"); const path_1 = require("path"); const simple_git_1 = require("simple-git"); const logger_1 = require("./logger"); class SpecWatcher extends events_1.EventEmitter { constructor(projectPath, parser) { super(); this.projectPath = projectPath; this.parser = parser; this.git = (0, simple_git_1.simpleGit)(projectPath); } async start() { const specsPath = (0, path_1.join)(this.projectPath, '.claude', 'specs'); (0, logger_1.debug)(`[Watcher] Starting to watch: ${specsPath}`); // Try to use FSEvents on macOS, fall back to polling if needed const isMacOS = process.platform === 'darwin'; this.watcher = (0, chokidar_1.watch)('.', { cwd: specsPath, ignored: /(^|[\\/])\.DS_Store/, // Only ignore .DS_Store persistent: true, ignoreInitial: true, // Use FSEvents on macOS if available, otherwise poll usePolling: !isMacOS, useFsEvents: isMacOS, // Polling fallback settings interval: isMacOS ? 100 : 1000, binaryInterval: 300, // Don't wait for write to finish - report changes immediately awaitWriteFinish: false, // Follow symlinks followSymlinks: true, // Emit all events ignorePermissionErrors: false, atomic: true, }); this.watcher .on('add', (path) => { (0, logger_1.debug)(`[Watcher] File added: ${path}`); this.handleFileChange('added', path); }) .on('change', (path) => { (0, logger_1.debug)(`[Watcher] File changed: ${path}`); this.handleFileChange('changed', path); }) .on('unlink', (path) => { (0, logger_1.debug)(`[Watcher] File removed: ${path}`); this.handleFileChange('removed', path); }) .on('addDir', (path) => { (0, logger_1.debug)(`[Watcher] Directory added: ${path}`); // When a new directory is added, we should check for .md files in it if (path) { // Check if this is a valid spec directory (top-level, alphanumeric with dashes) const parts = path.split(/[\\/]/); (0, logger_1.debug)(`[Watcher] Directory path parts: ${JSON.stringify(parts)}, length: ${parts.length}`); // Check if this is a top-level directory (no path separators) // On different systems, chokidar may report paths differently if ((parts.length === 1 || (parts.length === 2 && parts[0] === '')) && /^[a-z0-9-]+$/.test(parts[parts.length - 1])) { const dirName = parts[parts.length - 1]; (0, logger_1.debug)(`[Watcher] Valid spec directory detected: ${dirName}`); this.checkNewSpecDirectory(dirName); } else { (0, logger_1.debug)(`[Watcher] Skipping non-spec directory: ${path} (parts: ${JSON.stringify(parts)})`); } } }) .on('unlinkDir', (path) => { (0, logger_1.debug)(`[Watcher] Directory removed: ${path}`); // Emit a remove event for the spec if (path) { // Check if this is a valid spec directory (top-level, alphanumeric with dashes) const parts = path.split(/[\\/]/); (0, logger_1.debug)(`[Watcher] Directory removal path parts: ${JSON.stringify(parts)}, length: ${parts.length}`); if ((parts.length === 1 || (parts.length === 2 && parts[0] === '')) && /^[a-z0-9-]+$/.test(parts[parts.length - 1])) { const dirName = parts[parts.length - 1]; (0, logger_1.debug)(`[Watcher] Valid spec directory removal detected: ${dirName}`); this.emit('change', { type: 'removed', spec: dirName, file: 'directory', data: null, }); } else { (0, logger_1.debug)(`[Watcher] Skipping non-spec directory removal: ${path} (parts: ${JSON.stringify(parts)})`); } } }) .on('ready', () => (0, logger_1.debug)('[Watcher] Initial scan complete. Ready for changes.')) .on('error', (error) => console.error('[Watcher] Error:', error)); // Start watching bugs directory const bugsPath = (0, path_1.join)(this.projectPath, '.claude', 'bugs'); (0, logger_1.debug)(`[BugWatcher] Starting to watch: ${bugsPath}`); this.bugWatcher = (0, chokidar_1.watch)('.', { cwd: bugsPath, ignored: /(^|[\\/])\.DS_Store/, persistent: true, ignoreInitial: true, usePolling: !isMacOS, useFsEvents: isMacOS, interval: isMacOS ? 100 : 1000, binaryInterval: 300, awaitWriteFinish: false, followSymlinks: true, ignorePermissionErrors: false, atomic: true, }); this.bugWatcher .on('add', (path) => { (0, logger_1.debug)(`[BugWatcher] File added: ${path}`); this.handleBugFileChange('added', path); }) .on('change', (path) => { (0, logger_1.debug)(`[BugWatcher] File changed: ${path}`); this.handleBugFileChange('changed', path); }) .on('unlink', (path) => { (0, logger_1.debug)(`[BugWatcher] File removed: ${path}`); this.handleBugFileChange('removed', path); }) .on('addDir', (path) => { (0, logger_1.debug)(`[BugWatcher] Directory added: ${path}`); if (path) { // Check if this is a valid bug directory (top-level, alphanumeric with dashes) const parts = path.split(/[\\/]/); (0, logger_1.debug)(`[BugWatcher] Directory path parts: ${JSON.stringify(parts)}, length: ${parts.length}`); // Check if this is a top-level directory (no path separators) // On different systems, chokidar may report paths differently if ((parts.length === 1 || (parts.length === 2 && parts[0] === '')) && /^[a-z0-9-]+$/.test(parts[parts.length - 1])) { const dirName = parts[parts.length - 1]; (0, logger_1.debug)(`[BugWatcher] Valid bug directory detected: ${dirName}`); this.checkNewBugDirectory(dirName); } else { (0, logger_1.debug)(`[BugWatcher] Skipping non-bug directory: ${path} (parts: ${JSON.stringify(parts)})`); } } }) .on('unlinkDir', (path) => { (0, logger_1.debug)(`[BugWatcher] Directory removed: ${path}`); if (path) { // Check if this is a valid bug directory (top-level, alphanumeric with dashes) const parts = path.split(/[\\/]/); (0, logger_1.debug)(`[BugWatcher] Directory removal path parts: ${JSON.stringify(parts)}, length: ${parts.length}`); if ((parts.length === 1 || (parts.length === 2 && parts[0] === '')) && /^[a-z0-9-]+$/.test(parts[parts.length - 1])) { const dirName = parts[parts.length - 1]; (0, logger_1.debug)(`[BugWatcher] Valid bug directory removal detected: ${dirName}`); this.emit('bug-change', { type: 'removed', bug: dirName, file: 'directory', }); } else { (0, logger_1.debug)(`[BugWatcher] Skipping non-bug directory removal: ${path} (parts: ${JSON.stringify(parts)})`); } } }) .on('ready', () => (0, logger_1.debug)('[BugWatcher] Initial scan complete. Ready for changes.')) .on('error', (error) => { // Don't log error if bugs directory doesn't exist yet if (!error.message.includes('ENOENT')) { console.error('[BugWatcher] Error:', error); } }); // Start watching git files await this.startGitWatcher(); // Start watching steering documents await this.startSteeringWatcher(); } async startGitWatcher() { const gitPath = (0, path_1.join)(this.projectPath, '.git'); // Check if it's a git repository try { const isRepo = await this.git.checkIsRepo(); if (!isRepo) { (0, logger_1.debug)(`[GitWatcher] ${this.projectPath} is not a git repository`); return; } } catch { (0, logger_1.debug)(`[GitWatcher] Could not check git status for ${this.projectPath}`); return; } // Get initial git state try { const branchSummary = await this.git.branchLocal(); this.lastBranch = branchSummary.current; const log = await this.git.log({ maxCount: 1 }); this.lastCommit = log.latest?.hash.substring(0, 7); (0, logger_1.debug)(`[GitWatcher] Initial state - branch: ${this.lastBranch}, commit: ${this.lastCommit}`); } catch (error) { console.error('[GitWatcher] Error getting initial state:', error); } // Watch specific git files that indicate changes this.gitWatcher = (0, chokidar_1.watch)(['HEAD', 'logs/HEAD', 'refs/heads/**'], { cwd: gitPath, persistent: true, ignoreInitial: true, usePolling: false, useFsEvents: process.platform === 'darwin', awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 } }); this.gitWatcher .on('change', async (path) => { (0, logger_1.debug)(`[GitWatcher] Git file changed: ${path}`); await this.checkGitChanges(); }) .on('add', async (path) => { (0, logger_1.debug)(`[GitWatcher] Git file added: ${path}`); await this.checkGitChanges(); }); } async startSteeringWatcher() { const steeringPath = (0, path_1.join)(this.projectPath, '.claude', 'steering'); (0, logger_1.debug)(`[SteeringWatcher] Starting to watch: ${steeringPath}`); // Try to use FSEvents on macOS, fall back to polling if needed const isMacOS = process.platform === 'darwin'; this.steeringWatcher = (0, chokidar_1.watch)(['product.md', 'tech.md', 'structure.md'], { cwd: steeringPath, persistent: true, ignoreInitial: true, // Use FSEvents on macOS if available, otherwise poll usePolling: !isMacOS, useFsEvents: isMacOS, // Polling fallback settings interval: isMacOS ? 100 : 1000, binaryInterval: 300, // Don't wait for write to finish - report changes immediately awaitWriteFinish: false, // Follow symlinks followSymlinks: true, // Emit all events ignorePermissionErrors: true, // Don't fail if steering directory doesn't exist yet atomic: true, }); this.steeringWatcher .on('add', (path) => { (0, logger_1.debug)(`[SteeringWatcher] File added: ${path}`); this.handleSteeringChange('added', path); }) .on('change', (path) => { (0, logger_1.debug)(`[SteeringWatcher] File changed: ${path}`); this.handleSteeringChange('changed', path); }) .on('unlink', (path) => { (0, logger_1.debug)(`[SteeringWatcher] File removed: ${path}`); this.handleSteeringChange('removed', path); }) .on('ready', () => (0, logger_1.debug)('[SteeringWatcher] Initial scan complete. Ready for changes.')) .on('error', (error) => { // Don't log error if steering directory doesn't exist yet if (!error.message.includes('ENOENT')) { console.error('[SteeringWatcher] Error:', error); } }); } async checkGitChanges() { try { const branchSummary = await this.git.branchLocal(); const currentBranch = branchSummary.current; const log = await this.git.log({ maxCount: 1 }); const currentCommit = log.latest?.hash.substring(0, 7); let changed = false; const event = { type: 'branch-changed', branch: currentBranch, commit: currentCommit }; if (currentBranch !== this.lastBranch) { (0, logger_1.debug)(`[GitWatcher] Branch changed from ${this.lastBranch} to ${currentBranch}`); this.lastBranch = currentBranch; changed = true; event.type = 'branch-changed'; } if (currentCommit !== this.lastCommit) { (0, logger_1.debug)(`[GitWatcher] Commit changed from ${this.lastCommit} to ${currentCommit}`); this.lastCommit = currentCommit; changed = true; event.type = 'commit-changed'; } if (changed) { this.emit('git-change', event); } } catch (error) { console.error('[GitWatcher] Error checking git changes:', error); } } async handleSteeringChange(type, fileName) { (0, logger_1.debug)(`Steering change detected: ${type} - ${fileName}`); // Get updated steering status const steeringStatus = await this.parser.getProjectSteeringStatus(); this.emit('steering-change', { type, file: fileName, steeringStatus, }); } async handleFileChange(type, filePath) { (0, logger_1.debug)(`File change detected: ${type} - ${filePath}`); const parts = filePath.split(/[\\/]/); const specName = parts[0]; if (parts.length === 2 && parts[1].match(/^(requirements|design|tasks)\.md$/)) { // Add a small delay to ensure file write is complete if (type === 'changed') { await new Promise((resolve) => setTimeout(resolve, 100)); } const spec = await this.parser.getSpec(specName); (0, logger_1.debug)(`Emitting change for spec: ${specName}, file: ${parts[1]}`); // Log approval status for debugging if (spec) { if (parts[1] === 'requirements.md' && spec.requirements) { (0, logger_1.debug)(`Requirements approved: ${spec.requirements.approved}`); } else if (parts[1] === 'design.md' && spec.design) { (0, logger_1.debug)(`Design approved: ${spec.design.approved}`); } else if (parts[1] === 'tasks.md' && spec.tasks) { (0, logger_1.debug)(`Tasks approved: ${spec.tasks.approved}`); } } this.emit('change', { type, spec: specName, file: parts[1], data: spec, }); } } async checkNewSpecDirectory(dirPath) { // When a new directory is created, check for any .md files already in it const specName = dirPath; const spec = await this.parser.getSpec(specName); if (spec) { (0, logger_1.debug)(`Found spec in new directory: ${specName}`); this.emit('change', { type: 'added', spec: specName, file: 'directory', data: spec, }); } } async handleBugFileChange(type, filePath) { (0, logger_1.debug)(`Bug file change detected: ${type} - ${filePath}`); const parts = filePath.split(/[\\/]/); const bugName = parts[0]; if (parts.length === 2 && parts[1].match(/^(report|analysis|verification)\.md$/)) { // Add a small delay to ensure file write is complete if (type === 'changed') { await new Promise((resolve) => setTimeout(resolve, 100)); } const bug = await this.parser.getBug(bugName); (0, logger_1.debug)(`Emitting change for bug: ${bugName}, file: ${parts[1]}`); this.emit('bug-change', { type, bug: bugName, file: parts[1], data: type !== 'removed' ? bug : null, }); } } async checkNewBugDirectory(dirPath) { // When a new directory is created, check for any .md files already in it const bugName = dirPath; const bug = await this.parser.getBug(bugName); if (bug) { (0, logger_1.debug)(`Found bug in new directory: ${bugName}`); this.emit('bug-change', { type: 'added', bug: bugName, file: 'directory', data: bug, }); } } async stop() { if (this.watcher) { await this.watcher.close(); } if (this.bugWatcher) { await this.bugWatcher.close(); } if (this.gitWatcher) { await this.gitWatcher.close(); } if (this.steeringWatcher) { await this.steeringWatcher.close(); } } } exports.SpecWatcher = SpecWatcher; //# sourceMappingURL=watcher.js.map