| |
| |
| |
| |
|
|
| import path from 'path'; |
| import type { Feature, DescriptionHistoryEntry } from '@automaker/types'; |
| import { |
| createLogger, |
| atomicWriteJson, |
| readJsonWithRecovery, |
| logRecoveryWarning, |
| DEFAULT_BACKUP_COUNT, |
| } from '@automaker/utils'; |
| import * as secureFs from '../lib/secure-fs.js'; |
| import { |
| getFeaturesDir, |
| getFeatureDir, |
| getFeatureImagesDir, |
| getAppSpecPath, |
| ensureAutomakerDir, |
| } from '@automaker/platform'; |
| import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js'; |
|
|
| const logger = createLogger('FeatureLoader'); |
|
|
| |
| export type { Feature }; |
|
|
| export class FeatureLoader { |
| |
| |
| |
| getFeaturesDir(projectPath: string): string { |
| return getFeaturesDir(projectPath); |
| } |
|
|
| |
| |
| |
| getFeatureImagesDir(projectPath: string, featureId: string): string { |
| return getFeatureImagesDir(projectPath, featureId); |
| } |
|
|
| |
| |
| |
| private async deleteOrphanedImages( |
| projectPath: string, |
| oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined, |
| newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined |
| ): Promise<void> { |
| if (!oldPaths || oldPaths.length === 0) { |
| return; |
| } |
|
|
| |
| const oldPathSet = new Set(oldPaths.map((p) => (typeof p === 'string' ? p : p.path))); |
| const newPathSet = new Set((newPaths || []).map((p) => (typeof p === 'string' ? p : p.path))); |
|
|
| |
| for (const oldPath of oldPathSet) { |
| if (!newPathSet.has(oldPath)) { |
| try { |
| |
| await secureFs.unlink(oldPath); |
| logger.info(`Deleted orphaned image: ${oldPath}`); |
| } catch (error) { |
| |
| logger.warn(`Failed to delete image: ${oldPath}`, error); |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| private async migrateImages( |
| projectPath: string, |
| featureId: string, |
| imagePaths?: Array<string | { path: string; [key: string]: unknown }> |
| ): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> { |
| if (!imagePaths || imagePaths.length === 0) { |
| return imagePaths; |
| } |
|
|
| const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); |
| await secureFs.mkdir(featureImagesDir, { recursive: true }); |
|
|
| const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = []; |
|
|
| for (const imagePath of imagePaths) { |
| try { |
| const originalPath = typeof imagePath === 'string' ? imagePath : imagePath.path; |
|
|
| |
| if (originalPath.includes(`/features/${featureId}/images/`)) { |
| updatedPaths.push(imagePath); |
| continue; |
| } |
|
|
| |
| const fullOriginalPath = path.isAbsolute(originalPath) |
| ? originalPath |
| : path.join(projectPath, originalPath); |
|
|
| |
| try { |
| await secureFs.access(fullOriginalPath); |
| } catch { |
| logger.warn(`Image not found, skipping: ${fullOriginalPath}`); |
| continue; |
| } |
|
|
| |
| const filename = path.basename(originalPath); |
| const newPath = path.join(featureImagesDir, filename); |
|
|
| |
| await secureFs.copyFile(fullOriginalPath, newPath); |
| logger.info(`Copied image: ${originalPath} -> ${newPath}`); |
|
|
| |
| try { |
| await secureFs.unlink(fullOriginalPath); |
| } catch { |
| |
| } |
|
|
| |
| if (typeof imagePath === 'string') { |
| updatedPaths.push(newPath); |
| } else { |
| updatedPaths.push({ ...imagePath, path: newPath }); |
| } |
| } catch (error) { |
| logger.error(`Failed to migrate image:`, error); |
| |
| |
| throw error; |
| } |
| } |
|
|
| return updatedPaths; |
| } |
|
|
| |
| |
| |
| getFeatureDir(projectPath: string, featureId: string): string { |
| return getFeatureDir(projectPath, featureId); |
| } |
|
|
| |
| |
| |
| getFeatureJsonPath(projectPath: string, featureId: string): string { |
| return path.join(this.getFeatureDir(projectPath, featureId), 'feature.json'); |
| } |
|
|
| |
| |
| |
| getAgentOutputPath(projectPath: string, featureId: string): string { |
| return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md'); |
| } |
|
|
| |
| |
| |
| getRawOutputPath(projectPath: string, featureId: string): string { |
| return path.join(this.getFeatureDir(projectPath, featureId), 'raw-output.jsonl'); |
| } |
|
|
| |
| |
| |
| generateFeatureId(): string { |
| return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; |
| } |
|
|
| |
| |
| |
| async getAll(projectPath: string): Promise<Feature[]> { |
| try { |
| const featuresDir = this.getFeaturesDir(projectPath); |
|
|
| |
| try { |
| await secureFs.access(featuresDir); |
| } catch { |
| return []; |
| } |
|
|
| |
| |
| const entries = (await secureFs.readdir(featuresDir, { |
| withFileTypes: true, |
| })) as import('fs').Dirent[]; |
| const featureDirs = entries.filter((entry) => entry.isDirectory()); |
|
|
| |
| const featurePromises = featureDirs.map(async (dir) => { |
| const featureId = dir.name; |
| const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); |
|
|
| |
| const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
|
|
| if (!feature) { |
| return null; |
| } |
|
|
| if (!feature.id) { |
| logger.warn(`Feature ${featureId} missing required 'id' field, skipping`); |
| return null; |
| } |
|
|
| |
| |
| |
| |
| if (feature.titleGenerating) { |
| delete feature.titleGenerating; |
| } |
|
|
| return feature; |
| }); |
|
|
| const results = await Promise.all(featurePromises); |
| const features = results.filter((f): f is Feature => f !== null); |
|
|
| |
| features.sort((a, b) => { |
| const aTime = a.id ? parseInt(a.id.split('-')[1] || '0') : 0; |
| const bTime = b.id ? parseInt(b.id.split('-')[1] || '0') : 0; |
| return aTime - bTime; |
| }); |
|
|
| return features; |
| } catch (error) { |
| logger.error('Failed to get all features:', error); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| private normalizeTitle(title: string): string { |
| return title.toLowerCase().trim(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async findByTitle(projectPath: string, title: string): Promise<Feature | null> { |
| if (!title || !title.trim()) { |
| return null; |
| } |
|
|
| const normalizedTitle = this.normalizeTitle(title); |
| const features = await this.getAll(projectPath); |
|
|
| for (const feature of features) { |
| if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { |
| return feature; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async findDuplicateTitle( |
| projectPath: string, |
| title: string, |
| excludeFeatureId?: string |
| ): Promise<Feature | null> { |
| if (!title || !title.trim()) { |
| return null; |
| } |
|
|
| const normalizedTitle = this.normalizeTitle(title); |
| const features = await this.getAll(projectPath); |
|
|
| for (const feature of features) { |
| |
| if (excludeFeatureId && feature.id === excludeFeatureId) { |
| continue; |
| } |
|
|
| if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { |
| return feature; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| async get(projectPath: string, featureId: string): Promise<Feature | null> { |
| const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); |
|
|
| |
| const result = await readJsonWithRecovery<Feature | null>(featureJsonPath, null, { |
| maxBackups: DEFAULT_BACKUP_COUNT, |
| autoRestore: true, |
| }); |
|
|
| logRecoveryWarning(result, `Feature ${featureId}`, logger); |
|
|
| const feature = result.data; |
|
|
| |
| if (feature?.titleGenerating) { |
| delete feature.titleGenerating; |
| } |
|
|
| return feature; |
| } |
|
|
| |
| |
| |
| async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> { |
| const featureId = featureData.id || this.generateFeatureId(); |
| const featureDir = this.getFeatureDir(projectPath, featureId); |
| const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); |
|
|
| |
| await ensureAutomakerDir(projectPath); |
|
|
| |
| await secureFs.mkdir(featureDir, { recursive: true }); |
|
|
| |
| const migratedImagePaths = await this.migrateImages( |
| projectPath, |
| featureId, |
| featureData.imagePaths |
| ); |
|
|
| |
| const initialHistory: DescriptionHistoryEntry[] = []; |
| if (featureData.description && featureData.description.trim()) { |
| initialHistory.push({ |
| description: featureData.description, |
| timestamp: new Date().toISOString(), |
| source: 'initial', |
| }); |
| } |
|
|
| |
| const feature: Feature = { |
| category: featureData.category || 'Uncategorized', |
| description: featureData.description || '', |
| ...featureData, |
| id: featureId, |
| createdAt: featureData.createdAt || new Date().toISOString(), |
| imagePaths: migratedImagePaths, |
| descriptionHistory: initialHistory, |
| }; |
|
|
| |
| |
| |
| |
| const featureToWrite = { ...feature }; |
| delete featureToWrite.titleGenerating; |
|
|
| |
| await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| logger.info(`Created feature ${featureId}`); |
| return feature; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async update( |
| projectPath: string, |
| featureId: string, |
| updates: Partial<Feature>, |
| descriptionHistorySource?: 'enhance' | 'edit', |
| enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer', |
| preEnhancementDescription?: string |
| ): Promise<Feature> { |
| const feature = await this.get(projectPath, featureId); |
| if (!feature) { |
| throw new Error(`Feature ${featureId} not found`); |
| } |
|
|
| |
| let updatedImagePaths = updates.imagePaths; |
| if (updates.imagePaths !== undefined) { |
| |
| await this.deleteOrphanedImages(projectPath, feature.imagePaths, updates.imagePaths); |
|
|
| |
| updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths); |
| } |
|
|
| |
| let updatedHistory = feature.descriptionHistory || []; |
| if ( |
| updates.description !== undefined && |
| updates.description !== feature.description && |
| updates.description.trim() |
| ) { |
| const timestamp = new Date().toISOString(); |
|
|
| |
| |
| if ( |
| descriptionHistorySource === 'enhance' && |
| preEnhancementDescription && |
| preEnhancementDescription.trim() |
| ) { |
| |
| const lastEntry = updatedHistory[updatedHistory.length - 1]; |
| if (!lastEntry || lastEntry.description !== preEnhancementDescription) { |
| const preEnhanceEntry: DescriptionHistoryEntry = { |
| description: preEnhancementDescription, |
| timestamp, |
| source: updatedHistory.length === 0 ? 'initial' : 'edit', |
| }; |
| updatedHistory = [...updatedHistory, preEnhanceEntry]; |
| } |
| } |
|
|
| |
| const historyEntry: DescriptionHistoryEntry = { |
| description: updates.description, |
| timestamp, |
| source: descriptionHistorySource || 'edit', |
| ...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}), |
| }; |
| updatedHistory = [...updatedHistory, historyEntry]; |
| } |
|
|
| |
| const updatedFeature: Feature = { |
| ...feature, |
| ...updates, |
| ...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}), |
| descriptionHistory: updatedHistory, |
| }; |
|
|
| |
| const featureToWrite = { ...updatedFeature }; |
| delete featureToWrite.titleGenerating; |
|
|
| |
| const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); |
| await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); |
|
|
| logger.info(`Updated feature ${featureId}`); |
| return updatedFeature; |
| } |
|
|
| |
| |
| |
| async delete(projectPath: string, featureId: string): Promise<boolean> { |
| try { |
| const featureDir = this.getFeatureDir(projectPath, featureId); |
| await secureFs.rm(featureDir, { recursive: true, force: true }); |
| logger.info(`Deleted feature ${featureId}`); |
| return true; |
| } catch (error) { |
| logger.error(`Failed to delete feature ${featureId}:`, error); |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| async getAgentOutput(projectPath: string, featureId: string): Promise<string | null> { |
| try { |
| const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); |
| const content = (await secureFs.readFile(agentOutputPath, 'utf-8')) as string; |
| return content; |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return null; |
| } |
| logger.error(`Failed to get agent output for ${featureId}:`, error); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async getRawOutput(projectPath: string, featureId: string): Promise<string | null> { |
| try { |
| const rawOutputPath = this.getRawOutputPath(projectPath, featureId); |
| const content = (await secureFs.readFile(rawOutputPath, 'utf-8')) as string; |
| return content; |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return null; |
| } |
| logger.error(`Failed to get raw output for ${featureId}:`, error); |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| async saveAgentOutput(projectPath: string, featureId: string, content: string): Promise<void> { |
| const featureDir = this.getFeatureDir(projectPath, featureId); |
| await secureFs.mkdir(featureDir, { recursive: true }); |
|
|
| const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); |
| await secureFs.writeFile(agentOutputPath, content, 'utf-8'); |
| } |
|
|
| |
| |
| |
| async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> { |
| try { |
| const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); |
| await secureFs.unlink(agentOutputPath); |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { |
| throw error; |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async syncFeatureToAppSpec( |
| projectPath: string, |
| feature: Feature, |
| fileLocations?: string[] |
| ): Promise<boolean> { |
| try { |
| const appSpecPath = getAppSpecPath(projectPath); |
|
|
| |
| let specContent: string; |
| try { |
| specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string; |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`); |
| return false; |
| } |
| throw error; |
| } |
|
|
| |
| const featureName = feature.title || `Feature: ${feature.id}`; |
| const implementedFeature: ImplementedFeature = { |
| name: featureName, |
| description: feature.description, |
| ...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}), |
| }; |
|
|
| |
| const updatedSpecContent = addImplementedFeature(specContent, implementedFeature); |
|
|
| |
| if (updatedSpecContent === specContent) { |
| logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`); |
| return false; |
| } |
|
|
| |
| await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8'); |
|
|
| logger.info(`Synced feature "${featureName}" to app_spec.txt`); |
| return true; |
| } catch (error) { |
| logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error); |
| throw error; |
| } |
| } |
| } |
|
|