mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
- Throw error immediately when JSON extraction fails in generate-features-from-spec.ts to avoid redundant parsing attempt (feedback from Gemini Code Assist review) - Emit spec_regeneration_error event before throwing for consistency - Fix TypeScript cast in sync-spec.ts by using double cast through unknown 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
/**
|
|
* Sync spec with current codebase and feature state
|
|
*
|
|
* Updates the spec file based on:
|
|
* - Completed Automaker features
|
|
* - Code analysis for tech stack and implementations
|
|
* - Roadmap phase status updates
|
|
*/
|
|
|
|
import * as secureFs from '../../lib/secure-fs.js';
|
|
import type { EventEmitter } from '../../lib/events.js';
|
|
import { createLogger } from '@automaker/utils';
|
|
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
|
import { extractJson } from '../../lib/json-extractor.js';
|
|
import { getAppSpecPath } from '@automaker/platform';
|
|
import type { SettingsService } from '../../services/settings-service.js';
|
|
import {
|
|
getAutoLoadClaudeMdSetting,
|
|
getPhaseModelWithOverrides,
|
|
} from '../../lib/settings-helpers.js';
|
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
|
import {
|
|
extractImplementedFeatures,
|
|
extractTechnologyStack,
|
|
extractRoadmapPhases,
|
|
updateImplementedFeaturesSection,
|
|
updateTechnologyStack,
|
|
updateRoadmapPhaseStatus,
|
|
type ImplementedFeature,
|
|
type RoadmapPhase,
|
|
} from '../../lib/xml-extractor.js';
|
|
import { getNotificationService } from '../../services/notification-service.js';
|
|
|
|
const logger = createLogger('SpecSync');
|
|
|
|
/**
|
|
* Type for extracted tech stack JSON response
|
|
*/
|
|
interface TechStackExtractionResult {
|
|
technologies: string[];
|
|
}
|
|
|
|
/**
|
|
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
|
*/
|
|
const techStackOutputSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
technologies: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'List of technologies detected in the project',
|
|
},
|
|
},
|
|
required: ['technologies'],
|
|
} as const;
|
|
|
|
/**
|
|
* Result of a sync operation
|
|
*/
|
|
export interface SyncResult {
|
|
techStackUpdates: {
|
|
added: string[];
|
|
removed: string[];
|
|
};
|
|
implementedFeaturesUpdates: {
|
|
addedFromFeatures: string[];
|
|
removed: string[];
|
|
};
|
|
roadmapUpdates: Array<{ phaseName: string; newStatus: string }>;
|
|
summary: string;
|
|
}
|
|
|
|
/**
|
|
* Sync the spec with current codebase and feature state
|
|
*/
|
|
export async function syncSpec(
|
|
projectPath: string,
|
|
events: EventEmitter,
|
|
abortController: AbortController,
|
|
settingsService?: SettingsService
|
|
): Promise<SyncResult> {
|
|
logger.info('========== syncSpec() started ==========');
|
|
logger.info('projectPath:', projectPath);
|
|
|
|
const result: SyncResult = {
|
|
techStackUpdates: { added: [], removed: [] },
|
|
implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] },
|
|
roadmapUpdates: [],
|
|
summary: '',
|
|
};
|
|
|
|
// Read existing spec
|
|
const specPath = getAppSpecPath(projectPath);
|
|
let specContent: string;
|
|
|
|
try {
|
|
specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
|
logger.info(`Spec loaded successfully (${specContent.length} chars)`);
|
|
} catch (readError) {
|
|
logger.error('Failed to read spec file:', readError);
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_error',
|
|
error: 'No project spec found. Create or regenerate spec first.',
|
|
projectPath,
|
|
});
|
|
throw new Error('No project spec found');
|
|
}
|
|
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_progress',
|
|
content: '[Phase: sync] Starting spec sync...\n',
|
|
projectPath,
|
|
});
|
|
|
|
// Extract current state from spec
|
|
const currentImplementedFeatures = extractImplementedFeatures(specContent);
|
|
const currentTechStack = extractTechnologyStack(specContent);
|
|
const currentRoadmapPhases = extractRoadmapPhases(specContent);
|
|
|
|
logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`);
|
|
logger.info(`Current spec has ${currentTechStack.length} technologies`);
|
|
logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`);
|
|
|
|
// Load completed Automaker features
|
|
const featureLoader = new FeatureLoader();
|
|
const allFeatures = await featureLoader.getAll(projectPath);
|
|
const completedFeatures = allFeatures.filter(
|
|
(f) => f.status === 'completed' || f.status === 'verified'
|
|
);
|
|
|
|
logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`);
|
|
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_progress',
|
|
content: `Found ${completedFeatures.length} completed features to sync...\n`,
|
|
projectPath,
|
|
});
|
|
|
|
// Build new implemented features list from completed Automaker features
|
|
const newImplementedFeatures: ImplementedFeature[] = [];
|
|
const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase()));
|
|
|
|
for (const feature of completedFeatures) {
|
|
const name = feature.title || `Feature: ${feature.id}`;
|
|
if (!existingNames.has(name.toLowerCase())) {
|
|
newImplementedFeatures.push({
|
|
name,
|
|
description: feature.description || '',
|
|
});
|
|
result.implementedFeaturesUpdates.addedFromFeatures.push(name);
|
|
}
|
|
}
|
|
|
|
// Merge: keep existing + add new from completed features
|
|
const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures];
|
|
|
|
// Update spec with merged features
|
|
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
|
specContent = updateImplementedFeaturesSection(specContent, mergedFeatures);
|
|
logger.info(
|
|
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec`
|
|
);
|
|
}
|
|
|
|
// Analyze codebase for tech stack updates using AI
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_progress',
|
|
content: 'Analyzing codebase for technology updates...\n',
|
|
projectPath,
|
|
});
|
|
|
|
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
|
projectPath,
|
|
settingsService,
|
|
'[SpecSync]'
|
|
);
|
|
|
|
// Get model from phase settings with provider info
|
|
const {
|
|
phaseModel: phaseModelEntry,
|
|
provider,
|
|
credentials,
|
|
} = settingsService
|
|
? await getPhaseModelWithOverrides(
|
|
'specGenerationModel',
|
|
settingsService,
|
|
projectPath,
|
|
'[SpecSync]'
|
|
)
|
|
: {
|
|
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
|
|
provider: undefined,
|
|
credentials: undefined,
|
|
};
|
|
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
|
|
|
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
|
|
|
// Determine if we should use structured output based on model type
|
|
const useStructuredOutput = supportsStructuredOutput(model);
|
|
logger.info(
|
|
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
|
);
|
|
|
|
// Use AI to analyze tech stack
|
|
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
|
|
|
Current known technologies: ${currentTechStack.join(', ')}
|
|
|
|
Look at package.json, config files, and source code to identify:
|
|
- Frameworks (React, Vue, Express, etc.)
|
|
- Languages (TypeScript, JavaScript, Python, etc.)
|
|
- Build tools (Vite, Webpack, etc.)
|
|
- Databases (PostgreSQL, MongoDB, etc.)
|
|
- Key libraries and tools
|
|
|
|
Return ONLY this JSON format, no other text:
|
|
{
|
|
"technologies": ["Technology 1", "Technology 2", ...]
|
|
}`;
|
|
|
|
// Add explicit JSON instructions for non-Claude/Codex models
|
|
if (!useStructuredOutput) {
|
|
techAnalysisPrompt = `${techAnalysisPrompt}
|
|
|
|
CRITICAL INSTRUCTIONS:
|
|
1. DO NOT write any files. Return the JSON in your response only.
|
|
2. Your entire response should be valid JSON starting with { and ending with }.
|
|
3. No explanations, no markdown, no text before or after the JSON.`;
|
|
}
|
|
|
|
try {
|
|
const techResult = await streamingQuery({
|
|
prompt: techAnalysisPrompt,
|
|
model,
|
|
cwd: projectPath,
|
|
maxTurns: 10,
|
|
allowedTools: ['Read', 'Glob', 'Grep'],
|
|
abortController,
|
|
thinkingLevel,
|
|
readOnly: true,
|
|
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
|
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
|
outputFormat: useStructuredOutput
|
|
? {
|
|
type: 'json_schema',
|
|
schema: techStackOutputSchema,
|
|
}
|
|
: undefined,
|
|
onText: (text) => {
|
|
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
|
},
|
|
});
|
|
|
|
// Parse tech stack from response - prefer structured output if available
|
|
let parsedTechnologies: string[] | null = null;
|
|
|
|
if (techResult.structured_output) {
|
|
// Use structured output from Claude/Codex models
|
|
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
|
if (Array.isArray(structured.technologies)) {
|
|
parsedTechnologies = structured.technologies;
|
|
logger.info('✅ Received structured output for tech analysis');
|
|
}
|
|
} else {
|
|
// Fall back to text parsing for non-Claude/Codex models
|
|
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
|
logger,
|
|
requiredKey: 'technologies',
|
|
requireArray: true,
|
|
});
|
|
if (extracted && Array.isArray(extracted.technologies)) {
|
|
parsedTechnologies = extracted.technologies;
|
|
logger.info('✅ Extracted tech stack from text response');
|
|
} else {
|
|
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
|
}
|
|
}
|
|
|
|
if (parsedTechnologies) {
|
|
const newTechStack = parsedTechnologies;
|
|
|
|
// Calculate differences
|
|
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
|
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
|
|
|
for (const tech of newTechStack) {
|
|
if (!currentSet.has(tech.toLowerCase())) {
|
|
result.techStackUpdates.added.push(tech);
|
|
}
|
|
}
|
|
|
|
for (const tech of currentTechStack) {
|
|
if (!newSet.has(tech.toLowerCase())) {
|
|
result.techStackUpdates.removed.push(tech);
|
|
}
|
|
}
|
|
|
|
// Update spec with new tech stack if there are changes
|
|
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
|
specContent = updateTechnologyStack(specContent, newTechStack);
|
|
logger.info(
|
|
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to analyze tech stack:', error);
|
|
// Continue with other sync operations
|
|
}
|
|
|
|
// Update roadmap phase statuses based on completed features
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_progress',
|
|
content: 'Checking roadmap phase statuses...\n',
|
|
projectPath,
|
|
});
|
|
|
|
// For each phase, check if all its features are completed
|
|
// This is a heuristic - we check if the phase name appears in any feature titles/descriptions
|
|
for (const phase of currentRoadmapPhases) {
|
|
if (phase.status === 'completed') continue; // Already completed
|
|
|
|
// Check if this phase should be marked as completed
|
|
// A phase is considered complete if we have completed features that mention it
|
|
const phaseNameLower = phase.name.toLowerCase();
|
|
const relatedCompletedFeatures = completedFeatures.filter(
|
|
(f) =>
|
|
f.title?.toLowerCase().includes(phaseNameLower) ||
|
|
f.description?.toLowerCase().includes(phaseNameLower) ||
|
|
f.category?.toLowerCase().includes(phaseNameLower)
|
|
);
|
|
|
|
// If we have related completed features and the phase is still pending/in_progress,
|
|
// update it to in_progress or completed based on feature count
|
|
if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') {
|
|
const newStatus = 'in_progress';
|
|
specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus);
|
|
result.roadmapUpdates.push({ phaseName: phase.name, newStatus });
|
|
logger.info(`Updated phase "${phase.name}" to ${newStatus}`);
|
|
}
|
|
}
|
|
|
|
// Save updated spec
|
|
await secureFs.writeFile(specPath, specContent, 'utf-8');
|
|
logger.info('Spec saved successfully');
|
|
|
|
// Build summary
|
|
const summaryParts: string[] = [];
|
|
if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) {
|
|
summaryParts.push(
|
|
`Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features`
|
|
);
|
|
}
|
|
if (result.techStackUpdates.added.length > 0) {
|
|
summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`);
|
|
}
|
|
if (result.techStackUpdates.removed.length > 0) {
|
|
summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`);
|
|
}
|
|
if (result.roadmapUpdates.length > 0) {
|
|
summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`);
|
|
}
|
|
|
|
result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date';
|
|
|
|
// Create notification
|
|
const notificationService = getNotificationService();
|
|
await notificationService.createNotification({
|
|
type: 'spec_regeneration_complete',
|
|
title: 'Spec Sync Complete',
|
|
message: result.summary,
|
|
projectPath,
|
|
});
|
|
|
|
events.emit('spec-regeneration:event', {
|
|
type: 'spec_regeneration_complete',
|
|
message: `Spec sync complete! ${result.summary}`,
|
|
projectPath,
|
|
});
|
|
|
|
logger.info('========== syncSpec() completed ==========');
|
|
logger.info('Summary:', result.summary);
|
|
|
|
return result;
|
|
}
|