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