Update analyze-complexity with realtime feedback and enhanced complexity report (#70)

* Update analyze-complexity with realtime feedback

* PR fixes

* include changeset
This commit is contained in:
Joe Danziger
2025-04-01 19:57:19 -04:00
committed by GitHub
parent 7fef5ab488
commit 16f4d4b932
4 changed files with 697 additions and 78 deletions

View File

@@ -30,7 +30,9 @@ import {
getComplexityWithColor,
startLoadingIndicator,
stopLoadingIndicator,
createProgressBar
createProgressBar,
displayAnalysisProgress,
formatComplexitySummary
} from './ui.js';
import {
@@ -300,10 +302,45 @@ Return only the updated tasks as a valid JSON array.`
});
// Process the stream
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
responseText += chunk.delta.text;
let responseText = ''; // Define responseText variable
try {
let chunkCount = 0;
let isProcessing = true;
// Add a local check that gets set to false if SIGINT is received
const originalSigintHandler = sigintHandler;
// Enhance the SIGINT handler to set isProcessing to false
sigintHandler = () => {
isProcessing = false;
// Call original handler to do the rest of cleanup and exit
if (originalSigintHandler) originalSigintHandler();
};
for await (const chunk of stream) {
// Check if we should stop processing (SIGINT received)
if (!isProcessing) {
break;
}
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
responseText += chunk.delta.text;
chunkCount++;
}
}
// Restore original handler if we didn't get interrupted
if (isProcessing) {
sigintHandler = originalSigintHandler;
}
} catch (streamError) {
// Clean up the interval even if there's an error
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
throw streamError;
}
if (streamingInterval) clearInterval(streamingInterval);
@@ -2094,10 +2131,45 @@ async function addTask(tasksPath, prompt, dependencies = [], priority = 'medium'
}, 500);
// Process the stream
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
fullResponse += chunk.delta.text;
console.log(chalk.yellow('[DEBUG] Starting to process Claude stream'));
try {
let chunkCount = 0;
let isProcessing = true;
// Add a local check that gets set to false if SIGINT is received
const originalSigintHandler = sigintHandler;
// Enhance the SIGINT handler to set isProcessing to false
sigintHandler = () => {
isProcessing = false;
// Call original handler to do the rest of cleanup and exit
if (originalSigintHandler) originalSigintHandler();
};
for await (const chunk of stream) {
// Check if we should stop processing (SIGINT received)
if (!isProcessing) {
break;
}
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
fullResponse += chunk.delta.text;
chunkCount++;
}
}
// Restore original handler if we didn't get interrupted
if (isProcessing) {
sigintHandler = originalSigintHandler;
}
} catch (streamError) {
// Clean up the interval even if there's an error
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
throw streamError;
}
if (streamingInterval) clearInterval(streamingInterval);
@@ -2183,22 +2255,67 @@ async function analyzeTaskComplexity(options) {
const tasksPath = options.file || 'tasks/tasks.json';
const outputPath = options.output || 'scripts/task-complexity-report.json';
const modelOverride = options.model;
// Define streamingInterval at the top level of the function so the handler can access it
let streamingInterval = null;
// Add a debug listener at the process level to see if SIGINT is being received
const debugSignalListener = () => {};
process.on('SIGINT', debugSignalListener);
// Set up SIGINT (Control-C) handler to cancel the operation gracefully
let sigintHandler;
const registerSigintHandler = () => {
// Only register if not already registered
if (!sigintHandler) {
sigintHandler = () => {
// Try to clear any intervals before exiting
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
// Clear any terminal state
process.stdout.write('\r\x1B[K'); // Clear current line
console.log(chalk.yellow('\n\nAnalysis cancelled by user.'));
// Make sure we remove our event listeners before exiting
cleanupSigintHandler();
// Force exit after giving time for cleanup
setTimeout(() => {
process.exit(0);
}, 100);
};
process.on('SIGINT', sigintHandler);
}
};
// Clean up function to remove the handler when done
const cleanupSigintHandler = () => {
if (sigintHandler) {
process.removeListener('SIGINT', sigintHandler);
sigintHandler = null;
}
// Also remove the debug listener
process.removeListener('SIGINT', debugSignalListener);
};
const thresholdScore = parseFloat(options.threshold || '5');
const useResearch = options.research || false;
console.log(chalk.blue(`Analyzing task complexity and generating expansion recommendations...`));
// Initialize error tracking variable
let apiError = false;
try {
// Read tasks.json
console.log(chalk.blue(`Reading tasks from ${tasksPath}...`));
const tasksData = readJSON(tasksPath);
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks) || tasksData.tasks.length === 0) {
throw new Error('No tasks found in the tasks file');
}
console.log(chalk.blue(`Found ${tasksData.tasks.length} tasks to analyze.`));
// Prepare the prompt for the LLM
const prompt = generateComplexityAnalysisPrompt(tasksData);
@@ -2206,13 +2323,51 @@ async function analyzeTaskComplexity(options) {
const loadingIndicator = startLoadingIndicator('Calling AI to analyze task complexity...');
let fullResponse = '';
let streamingInterval = null;
try {
// If research flag is set, use Perplexity first
if (useResearch) {
try {
console.log(chalk.blue('Using Perplexity AI for research-backed complexity analysis...'));
// Register SIGINT handler to allow cancellation with Control-C
registerSigintHandler();
// Start tracking elapsed time and update information display
const startTime = Date.now();
const totalTaskCount = tasksData.tasks.length;
// IMPORTANT: Stop the loading indicator before showing the progress bar
stopLoadingIndicator(loadingIndicator);
// Set up the progress data
const progressData = {
model: process.env.PERPLEXITY_MODEL || 'sonar-pro',
contextTokens: 0,
elapsed: 0,
temperature: CONFIG.temperature,
tasksAnalyzed: 0,
totalTasks: totalTaskCount,
percentComplete: 0,
maxTokens: CONFIG.maxTokens
};
// Estimate context tokens (rough approximation - 1 token ~= 4 chars)
const estimatedContextTokens = Math.ceil(prompt.length / 4);
progressData.contextTokens = estimatedContextTokens;
// Display initial progress before API call begins
displayAnalysisProgress(progressData);
// Update progress display at regular intervals
streamingInterval = setInterval(() => {
// Update elapsed time
progressData.elapsed = (Date.now() - startTime) / 1000;
progressData.percentComplete = Math.min(90, (progressData.elapsed / 30) * 100); // Estimate based on typical 30s completion
// Estimate number of tasks analyzed based on percentage
progressData.tasksAnalyzed = Math.floor((progressData.percentComplete / 100) * totalTaskCount);
displayAnalysisProgress(progressData);
}, 100);
// Modify prompt to include more context for Perplexity and explicitly request JSON
const researchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks.
@@ -2258,17 +2413,36 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
fullResponse = result.choices[0].message.content;
console.log(chalk.green('Successfully generated complexity analysis with Perplexity AI'));
if (streamingInterval) clearInterval(streamingInterval);
stopLoadingIndicator(loadingIndicator);
// Clean up the interval
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
// ALWAYS log the first part of the response for debugging
console.log(chalk.gray('Response first 200 chars:'));
console.log(chalk.gray(fullResponse.substring(0, 200)));
// Show completion
progressData.percentComplete = 100;
progressData.tasksAnalyzed = progressData.totalTasks;
progressData.completed = true;
displayAnalysisProgress(progressData);
stopLoadingIndicator(loadingIndicator);
// Log the first part of the response for debugging
console.debug(chalk.gray('Response first 200 chars:'));
console.debug(chalk.gray(fullResponse.substring(0, 200)));
} catch (perplexityError) {
console.log(chalk.yellow('Falling back to Claude for complexity analysis...'));
console.log(chalk.gray('Perplexity error:'), perplexityError.message);
console.error(chalk.yellow('Falling back to Claude for complexity analysis...'));
console.error(chalk.gray('Perplexity error:'), perplexityError.message);
// Clean up
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
cleanupSigintHandler();
// Continue to Claude as fallback
console.log(chalk.yellow('\nFalling back to Claude after Perplexity error: ' + perplexityError.message));
await useClaudeForComplexityAnalysis();
}
} else {
@@ -2278,8 +2452,13 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
// Helper function to use Claude for complexity analysis
async function useClaudeForComplexityAnalysis() {
// Register SIGINT handler to allow cancellation with Control-C
registerSigintHandler();
// Call the LLM API with streaming
const stream = await anthropic.messages.create({
// Add try-catch for better error handling specifically for API call
try {
const stream = await anthropic.messages.create({
max_tokens: CONFIG.maxTokens,
model: modelOverride || CONFIG.model,
temperature: CONFIG.temperature,
@@ -2288,13 +2467,54 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
stream: true
});
// Update loading indicator to show streaming progress
let dotCount = 0;
// Stop the default loading indicator before showing our custom UI
stopLoadingIndicator(loadingIndicator);
// Start tracking elapsed time and update information display
const startTime = Date.now();
const totalTaskCount = tasksData.tasks.length;
// Set up the progress data
const progressData = {
model: modelOverride || CONFIG.model,
contextTokens: 0, // Will estimate based on prompt size
elapsed: 0,
temperature: CONFIG.temperature,
tasksAnalyzed: 0,
totalTasks: totalTaskCount,
percentComplete: 0,
maxTokens: CONFIG.maxTokens
};
// Estimate context tokens (rough approximation - 1 token ~= 4 chars)
const estimatedContextTokens = Math.ceil(prompt.length / 4);
progressData.contextTokens = estimatedContextTokens;
// Display initial progress before streaming begins
displayAnalysisProgress(progressData);
// Update progress display at regular intervals
streamingInterval = setInterval(() => {
readline.cursorTo(process.stdout, 0);
process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`);
dotCount = (dotCount + 1) % 4;
}, 500);
// Update elapsed time
progressData.elapsed = (Date.now() - startTime) / 1000;
// Estimate completion percentage based on response length
if (fullResponse.length > 0) {
// Estimate based on expected response size (approx. 500 chars per task)
const expectedResponseSize = totalTaskCount * 500;
const estimatedProgress = Math.min(95, (fullResponse.length / expectedResponseSize) * 100);
progressData.percentComplete = estimatedProgress;
// Estimate analyzed tasks based on JSON objects found
const taskMatches = fullResponse.match(/"taskId"\s*:\s*\d+/g);
if (taskMatches) {
progressData.tasksAnalyzed = Math.min(totalTaskCount, taskMatches.length);
}
}
// Display the progress information
displayAnalysisProgress(progressData);
}, 100); // Update much more frequently for smoother animation
// Process the stream
for await (const chunk of stream) {
@@ -2303,14 +2523,48 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
}
}
clearInterval(streamingInterval);
stopLoadingIndicator(loadingIndicator);
// Clean up the interval - stop updating progress
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
console.log(chalk.green("Completed streaming response from Claude API!"));
// Show completion message immediately
progressData.percentComplete = 100;
progressData.elapsed = (Date.now() - startTime) / 1000;
progressData.tasksAnalyzed = progressData.totalTasks;
progressData.completed = true;
progressData.contextTokens = Math.max(progressData.contextTokens, estimatedContextTokens); // Ensure the final token count is accurate
displayAnalysisProgress(progressData);
// Clear the line completely to remove any artifacts (after showing completion)
process.stdout.write('\r\x1B[K'); // Clear current line
process.stdout.write('\r'); // Move cursor to beginning of line
} catch (apiError) {
// Handle specific API errors here
if (streamingInterval) clearInterval(streamingInterval);
process.stdout.write('\r\x1B[K'); // Clear current line
console.error(chalk.red(`\nAPI Error: ${apiError.message || 'Unknown error'}\n`));
console.log(chalk.yellow('This might be a temporary issue with the Claude API.'));
console.log(chalk.yellow('Please try again in a few moments or check your API key.'));
// Rethrow to be caught by outer handler
throw apiError;
} finally {
// Clean up SIGINT handler
cleanupSigintHandler();
// Ensure the interval is cleared
if (streamingInterval) {
clearInterval(streamingInterval);
streamingInterval = null;
}
}
}
// Parse the JSON response
console.log(chalk.blue(`Parsing complexity analysis...`));
console.log(chalk.blue(` Parsing complexity analysis...`));
let complexityAnalysis;
try {
// Clean up the response to ensure it's valid JSON
@@ -2320,14 +2574,14 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
const codeBlockMatch = fullResponse.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch) {
cleanedResponse = codeBlockMatch[1];
console.log(chalk.blue("Extracted JSON from code block"));
console.debug(chalk.blue("Extracted JSON from code block"));
} else {
// Look for a complete JSON array pattern
// This regex looks for an array of objects starting with [ and ending with ]
const jsonArrayMatch = fullResponse.match(/(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/);
if (jsonArrayMatch) {
cleanedResponse = jsonArrayMatch[1];
console.log(chalk.blue("Extracted JSON array pattern"));
console.log(chalk.blue(" Extracted JSON array pattern"));
} else {
// Try to find the start of a JSON array and capture to the end
const jsonStartMatch = fullResponse.match(/(\[\s*\{[\s\S]*)/);
@@ -2342,19 +2596,19 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
}
}
}
// Log the cleaned response for debugging
console.log(chalk.gray("Attempting to parse cleaned JSON..."));
console.log(chalk.gray("Cleaned response (first 100 chars):"));
console.log(chalk.gray(cleanedResponse.substring(0, 100)));
console.log(chalk.gray("Last 100 chars:"));
console.log(chalk.gray(cleanedResponse.substring(cleanedResponse.length - 100)));
console.debug(chalk.gray("Attempting to parse cleaned JSON..."));
console.debug(chalk.gray("Cleaned response (first 100 chars):"));
console.debug(chalk.gray(cleanedResponse.substring(0, 100)));
console.debug(chalk.gray("Last 100 chars:"));
console.debug(chalk.gray(cleanedResponse.substring(cleanedResponse.length - 100)));
// More aggressive cleaning - strip any non-JSON content at the beginning or end
const strictArrayMatch = cleanedResponse.match(/(\[\s*\{[\s\S]*\}\s*\])/);
if (strictArrayMatch) {
cleanedResponse = strictArrayMatch[1];
console.log(chalk.blue("Applied strict JSON array extraction"));
console.debug(chalk.blue("Applied strict JSON array extraction"));
}
try {
@@ -2470,6 +2724,45 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
// Use the same AI model as the original analysis
if (useResearch) {
// Register SIGINT handler again to make sure it's active for this phase
registerSigintHandler();
// Start tracking elapsed time for missing tasks
const missingTasksStartTime = Date.now();
// Stop the loading indicator before showing progress
stopLoadingIndicator(missingTasksLoadingIndicator);
// Set up progress tracking for missing tasks
const missingProgressData = {
model: process.env.PERPLEXITY_MODEL || 'sonar-pro',
contextTokens: 0,
elapsed: 0,
temperature: CONFIG.temperature,
tasksAnalyzed: 0,
totalTasks: missingTaskIds.length,
percentComplete: 0,
maxTokens: CONFIG.maxTokens
};
// Estimate context tokens
const estimatedMissingContextTokens = Math.ceil(missingTasksPrompt.length / 4);
missingProgressData.contextTokens = estimatedMissingContextTokens;
// Display initial progress
displayAnalysisProgress(missingProgressData);
// Update progress display regularly
const missingTasksInterval = setInterval(() => {
missingProgressData.elapsed = (Date.now() - missingTasksStartTime) / 1000;
missingProgressData.percentComplete = Math.min(90, (missingProgressData.elapsed / 20) * 100); // Estimate ~20s completion
// Estimate number of tasks analyzed based on percentage
missingProgressData.tasksAnalyzed = Math.floor((missingProgressData.percentComplete / 100) * missingTaskIds.length);
displayAnalysisProgress(missingProgressData);
}, 100);
// Create the same research prompt but for missing tasks
const missingTasksResearchPrompt = `You are conducting a detailed analysis of software development tasks to determine their complexity and how they should be broken down into subtasks.
@@ -2494,24 +2787,45 @@ Your response must be a clean JSON array only, following exactly this format:
DO NOT include any text before or after the JSON array. No explanations, no markdown formatting.`;
const result = await perplexity.chat.completions.create({
model: process.env.PERPLEXITY_MODEL || 'sonar-pro',
messages: [
{
role: "system",
content: "You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response."
},
{
role: "user",
content: missingTasksResearchPrompt
}
],
temperature: CONFIG.temperature,
max_tokens: CONFIG.maxTokens,
});
// Extract the response
missingAnalysisResponse = result.choices[0].message.content;
try {
const result = await perplexity.chat.completions.create({
model: process.env.PERPLEXITY_MODEL || 'sonar-pro',
messages: [
{
role: "system",
content: "You are a technical analysis AI that only responds with clean, valid JSON. Never include explanatory text or markdown formatting in your response."
},
{
role: "user",
content: missingTasksResearchPrompt
}
],
temperature: CONFIG.temperature,
max_tokens: CONFIG.maxTokens,
});
// Extract the response
missingAnalysisResponse = result.choices[0].message.content;
// Stop interval and show completion
clearInterval(missingTasksInterval);
missingProgressData.percentComplete = 100;
missingProgressData.tasksAnalyzed = missingProgressData.totalTasks;
missingProgressData.completed = true;
displayAnalysisProgress(missingProgressData);
} catch (error) {
// Clean up on error
if (missingTasksInterval) {
clearInterval(missingTasksInterval);
}
throw error;
} finally {
// Always clean up SIGINT handler and interval
cleanupSigintHandler();
if (missingTasksInterval) {
clearInterval(missingTasksInterval);
}
}
} else {
// Use Claude
const stream = await anthropic.messages.create({
@@ -2547,7 +2861,7 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
const codeBlockMatch = missingAnalysisResponse.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch) {
cleanedResponse = codeBlockMatch[1];
console.log(chalk.blue("Extracted JSON from code block for missing tasks"));
console.debug(chalk.blue("Extracted JSON from code block for missing tasks"));
} else {
// Look for a complete JSON array pattern
const jsonArrayMatch = missingAnalysisResponse.match(/(\[\s*\{\s*"[^"]*"\s*:[\s\S]*\}\s*\])/);
@@ -2669,10 +2983,10 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
};
// Write the report to file
console.log(chalk.blue(`Writing complexity report to ${outputPath}...`));
console.log(chalk.blue(` Writing complexity report to ${outputPath}...`));
writeJSON(outputPath, report);
console.log(chalk.green(`Task complexity analysis complete. Report written to ${outputPath}`));
console.log(chalk.green(` Task complexity analysis complete. Report written to ${outputPath}`));
// Display a summary of findings
const highComplexity = complexityAnalysis.filter(t => t.complexityScore >= 8).length;
@@ -2680,24 +2994,43 @@ DO NOT include any text before or after the JSON array. No explanations, no mark
const lowComplexity = complexityAnalysis.filter(t => t.complexityScore < 5).length;
const totalAnalyzed = complexityAnalysis.length;
console.log('\nComplexity Analysis Summary:');
console.log('----------------------------');
console.log(`Tasks in input file: ${tasksData.tasks.length}`);
console.log(`Tasks successfully analyzed: ${totalAnalyzed}`);
console.log(`High complexity tasks: ${highComplexity}`);
console.log(`Medium complexity tasks: ${mediumComplexity}`);
console.log(`Low complexity tasks: ${lowComplexity}`);
console.log(`Sum verification: ${highComplexity + mediumComplexity + lowComplexity} (should equal ${totalAnalyzed})`);
console.log(`Research-backed analysis: ${useResearch ? 'Yes' : 'No'}`);
console.log(`\nSee ${outputPath} for the full report and expansion commands.`);
// Only show summary if we didn't encounter an API error
if (!apiError) {
// Create a summary object for formatting
const summary = {
totalTasks: tasksData.tasks.length,
analyzedTasks: totalAnalyzed,
highComplexityCount: highComplexity,
mediumComplexityCount: mediumComplexity,
lowComplexityCount: lowComplexity,
researchBacked: useResearch
};
// Use the new formatting function from UI module
console.log(formatComplexitySummary(summary));
}
} catch (error) {
if (streamingInterval) clearInterval(streamingInterval);
stopLoadingIndicator(loadingIndicator);
throw error;
// Mark that we encountered an API error
apiError = true;
// Display a user-friendly error message
console.error(chalk.red(`\nAPI Error: ${error.message || 'Unknown error'}\n`));
console.log(chalk.yellow('This might be a temporary issue with the Claude API.'));
console.log(chalk.yellow('Please try again in a few moments.'));
cleanupSigintHandler();
// We'll continue with any tasks we might have analyzed before the error
}
} catch (error) {
console.error(chalk.red(`Error analyzing task complexity: ${error.message}`));
// Clean up SIGINT handler
cleanupSigintHandler();
process.exit(1);
}
}