story-weaver-ai
Version:
A narrative development system for AI-driven storytelling with Jungian psychology
837 lines (705 loc) • 27.9 kB
JavaScript
/**
* task-manager.js
* Task management functions for the Task Master CLI
*/
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import boxen from 'boxen';
import Table from 'cli-table3';
import readline from 'readline';
import { Anthropic } from '@anthropic-ai/sdk';
import {
CONFIG,
log,
readJSON,
writeJSON,
sanitizePrompt,
findTaskById,
readComplexityReport,
findTaskInComplexityReport,
truncate
} from './utils.js';
import {
displayBanner,
getStatusWithColor,
formatDependenciesWithStatus,
getComplexityWithColor,
startLoadingIndicator,
stopLoadingIndicator,
createProgressBar
} from './ui.js';
import {
callClaude,
generateSubtasks,
generateSubtasksWithPerplexity,
generateComplexityAnalysisPrompt
} from './ai-services.js';
import {
validateTaskDependencies,
validateAndFixDependencies
} from './dependency-manager.js';
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Import perplexity if available
let perplexity;
try {
if (process.env.PERPLEXITY_API_KEY) {
// Using the existing approach from ai-services.js
const OpenAI = (await import('openai')).default;
perplexity = new OpenAI({
apiKey: process.env.PERPLEXITY_API_KEY,
baseURL: 'https://api.perplexity.ai',
});
log('info', `Initialized Perplexity client with OpenAI compatibility layer`);
}
} catch (error) {
log('warn', `Failed to initialize Perplexity client: ${error.message}`);
log('warn', 'Research-backed features will not be available');
}
/**
* Parse a PRD file and generate tasks
* @param {string} prdPath - Path to the PRD file
* @param {string} tasksPath - Path to the tasks.json file
* @param {number} numTasks - Number of tasks to generate (optional, will be determined automatically if not provided)
*/
async function parsePRD(prdPath, tasksPath, numTasks = null) {
try {
log('info', `Parsing PRD file: ${prdPath}`);
// Read the PRD content
const prdContent = fs.readFileSync(prdPath, 'utf8');
// Call Claude to generate tasks with intelligent task count determination
const tasksData = await callClaudeForTasks(prdContent, prdPath, numTasks);
// Create the directory if it doesn't exist
const tasksDir = path.dirname(tasksPath);
if (!fs.existsSync(tasksDir)) {
fs.mkdirSync(tasksDir, { recursive: true });
}
// Write the tasks to the file
writeJSON(tasksPath, tasksData);
log('success', `Successfully generated ${tasksData.tasks.length} tasks from PRD`);
log('info', `Tasks saved to: ${tasksPath}`);
// Generate individual task files
await generateTaskFiles(tasksPath, tasksDir);
console.log(boxen(
chalk.green(`Successfully generated ${tasksData.tasks.length} tasks from PRD`),
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
));
console.log(boxen(
chalk.white.bold('Next Steps:') + '\n\n' +
`${chalk.cyan('1.')} Run ${chalk.yellow('task-master list')} to view all tasks\n` +
`${chalk.cyan('2.')} Run ${chalk.yellow('task-master expand --id=<id>')} to break down a task into subtasks`,
{ padding: 1, borderColor: 'cyan', borderStyle: 'round', margin: { top: 1 } }
));
} catch (error) {
log('error', `Error parsing PRD: ${error.message}`);
console.error(chalk.red(`Error: ${error.message}`));
if (CONFIG.debug) {
console.error(error);
}
process.exit(1);
}
}
/**
* Update tasks based on new context
* @param {string} tasksPath - Path to the tasks.json file
* @param {number} fromId - Task ID to start updating from
* @param {string} prompt - Prompt with new context
* @param {boolean} useResearch - Whether to use Perplexity AI for research
*/
async function updateTasks(tasksPath, fromId, prompt, useResearch = false) {
try {
log('info', `Updating tasks from ID ${fromId} with prompt: "${prompt}"`);
// Validate research flag
if (useResearch && (!perplexity || !process.env.PERPLEXITY_API_KEY)) {
log('warn', 'Perplexity AI is not available. Falling back to Claude AI.');
console.log(chalk.yellow('Perplexity AI is not available (API key may be missing). Falling back to Claude AI.'));
useResearch = false;
}
// Read the tasks file
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
throw new Error(`No valid tasks found in ${tasksPath}`);
}
// Find tasks to update (ID >= fromId and not 'done')
const tasksToUpdate = data.tasks.filter(task => task.id >= fromId && task.status !== 'done');
if (tasksToUpdate.length === 0) {
log('info', `No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`);
console.log(chalk.yellow(`No tasks to update (all tasks with ID >= ${fromId} are already marked as done)`));
return;
}
// Show the tasks that will be updated
const table = new Table({
head: [
chalk.cyan.bold('ID'),
chalk.cyan.bold('Title'),
chalk.cyan.bold('Status')
],
colWidths: [5, 60, 10]
});
tasksToUpdate.forEach(task => {
table.push([
task.id,
truncate(task.title, 57),
getStatusWithColor(task.status)
]);
});
console.log(boxen(
chalk.white.bold(`Updating ${tasksToUpdate.length} tasks`),
{ padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 1, bottom: 0 } }
));
console.log(table.toString());
// Build the system prompt
const systemPrompt = `You are an AI assistant helping to update software development tasks based on new context.
You will be given a set of tasks and a prompt describing changes or new implementation details.
Your job is to update the tasks to reflect these changes, while preserving their basic structure.
Guidelines:
1. Maintain the same IDs, statuses, and dependencies unless specifically mentioned in the prompt
2. Update titles, descriptions, details, and test strategies to reflect the new information
3. Do not change anything unnecessarily - just adapt what needs to change based on the prompt
4. You should return ALL the tasks in order, not just the modified ones
5. Return a complete valid JSON object with the updated tasks array
The changes described in the prompt should be applied to ALL tasks in the list.`;
const taskData = JSON.stringify(tasksToUpdate, null, 2);
let updatedTasks;
const loadingIndicator = startLoadingIndicator(useResearch
? 'Updating tasks with Perplexity AI research...'
: 'Updating tasks with Claude AI...');
try {
if (useResearch) {
log('info', 'Using Perplexity AI for research-backed task updates');
// Call Perplexity AI using format consistent with ai-services.js
const perplexityModel = process.env.PERPLEXITY_MODEL || 'sonar-pro';
const result = await perplexity.chat.completions.create({
model: perplexityModel,
messages: [
{
role: "system",
content: `${systemPrompt}\n\nAdditionally, please research the latest best practices, implementation details, and considerations when updating these tasks. Use your online search capabilities to gather relevant information.`
},
{
role: "user",
content: `Here are the tasks to update:
${taskData}
Please update these tasks based on the following new context:
${prompt}
Return only the updated tasks as a valid JSON array.`
}
],
temperature: parseFloat(process.env.TEMPERATURE || CONFIG.temperature),
max_tokens: parseInt(process.env.MAX_TOKENS || CONFIG.maxTokens),
});
const responseText = result.choices[0].message.content;
// Extract JSON from response
const jsonStart = responseText.indexOf('[');
const jsonEnd = responseText.lastIndexOf(']');
if (jsonStart === -1 || jsonEnd === -1) {
throw new Error("Could not find valid JSON array in Perplexity's response");
}
const jsonText = responseText.substring(jsonStart, jsonEnd + 1);
updatedTasks = JSON.parse(jsonText);
} else {
// Call Claude to update the tasks with streaming enabled
let responseText = '';
let streamingInterval = null;
try {
// Update loading indicator to show streaming progress
let dotCount = 0;
const readline = await import('readline');
streamingInterval = setInterval(() => {
readline.cursorTo(process.stdout, 0);
process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`);
dotCount = (dotCount + 1) % 4;
}, 500);
// Use streaming API call
const stream = await anthropic.messages.create({
model: CONFIG.model,
max_tokens: CONFIG.maxTokens,
temperature: CONFIG.temperature,
system: systemPrompt,
messages: [
{
role: 'user',
content: `Here are the tasks to update:
${taskData}
Please update these tasks based on the following new context:
${prompt}
Return only the updated tasks as a valid JSON array.`
}
],
stream: true
});
// Process the stream
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
responseText += chunk.delta.text;
}
}
if (streamingInterval) clearInterval(streamingInterval);
log('info', "Completed streaming response from Claude API!");
// Extract JSON from response
const jsonStart = responseText.indexOf('[');
const jsonEnd = responseText.lastIndexOf(']');
if (jsonStart === -1 || jsonEnd === -1) {
throw new Error("Could not find valid JSON array in Claude's response");
}
const jsonText = responseText.substring(jsonStart, jsonEnd + 1);
updatedTasks = JSON.parse(jsonText);
} catch (error) {
if (streamingInterval) clearInterval(streamingInterval);
throw error;
}
}
// Replace the tasks in the original data
updatedTasks.forEach(updatedTask => {
const index = data.tasks.findIndex(t => t.id === updatedTask.id);
if (index !== -1) {
data.tasks[index] = updatedTask;
}
});
// Write the updated tasks to the file
writeJSON(tasksPath, data);
log('success', `Successfully updated ${updatedTasks.length} tasks`);
// Generate individual task files
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
console.log(boxen(
chalk.green(`Successfully updated ${updatedTasks.length} tasks`),
{ padding: 1, borderColor: 'green', borderStyle: 'round' }
));
} finally {
stopLoadingIndicator(loadingIndicator);
}
} catch (error) {
log('error', `Error updating tasks: ${error.message}`);
console.error(chalk.red(`Error: ${error.message}`));
if (CONFIG.debug) {
console.error(error);
}
process.exit(1);
}
}
/**
* Configure the output format for task files
* @param {string} tasksPath - Path to tasks.json
* @param {string} outputDir - Output directory
* @param {Object} options - Additional options
* @param {string} options.format - Output format (markdown, json, yaml)
* @returns {Promise<Object>} Result object
*/
async function generateTaskFiles(tasksPath, outputDir, options = {}) {
try {
const { format = 'markdown' } = options;
log('info', `Generating task files from ${tasksPath} in ${format} format`);
// Ensure output directory exists
await fs.mkdir(outputDir, { recursive: true });
// Load tasks
const tasksData = await readJSON(tasksPath);
if (!tasksData.tasks || !Array.isArray(tasksData.tasks)) {
throw new Error('Invalid tasks data: missing tasks array');
}
// Generate a file for each task
const results = await Promise.all(tasksData.tasks.map(task => {
return generateTaskFile(task, outputDir, { format });
}));
log('success', `Generated ${results.length} task files in ${outputDir}`);
console.log(chalk.green(`Generated ${results.length} task files in ${outputDir}`));
return {
totalTasks: results.length,
outputDir,
format
};
} catch (error) {
log('error', `Failed to generate task files: ${error.message}`);
console.error(chalk.red(`Failed to generate task files: ${error.message}`));
if (CONFIG.debug) {
console.error(error);
}
throw error;
}
}
/**
* Generate a single task file
* @param {Object} task - Task object
* @param {string} outputDir - Output directory
* @param {Object} options - Additional options
* @param {string} options.format - Output format (markdown, json, yaml)
* @returns {Promise<Object>} Result object
*/
async function generateTaskFile(task, outputDir, options = {}) {
const { format = 'markdown' } = options;
try {
// Create a sanitized filename from the task title
const filename = sanitizeFilename(task.title)
.toLowerCase()
.replace(/\s+/g, '-');
let extension;
let content;
// Generate content based on format
switch (format.toLowerCase()) {
case 'json':
extension = '.json';
content = JSON.stringify(task, null, 2);
break;
case 'yaml':
case 'yml':
const yaml = (await import('js-yaml')).default;
extension = '.yaml';
content = yaml.dump(task);
break;
case 'markdown':
case 'md':
default:
extension = '.md';
content = generateTaskMarkdown(task);
break;
}
// Build the filepath
const filepath = path.join(outputDir, `task_${String(task.id).padStart(3, '0')}-${filename}${extension}`);
// Write the file
await fs.writeFile(filepath, content, 'utf8');
log('info', `Created task file: ${filepath}`);
return {
task,
filepath,
format
};
} catch (error) {
log('error', `Failed to generate file for task ${task.id}: ${error.message}`);
throw error;
}
}
/**
* Generate markdown content for a task
* @param {Object} task - Task object
* @returns {string} Markdown content
*/
function generateTaskMarkdown(task) {
let content = `# Task: ${task.title}\n\n`;
// Add task ID and status
content += `**ID:** ${task.id}\n`;
content += `**Status:** ${task.status || 'pending'}\n`;
// Add dependencies if any
if (task.dependencies && task.dependencies.length > 0) {
content += `**Dependencies:** ${task.dependencies.join(', ')}\n`;
}
// Add priority if available
if (task.priority) {
content += `**Priority:** ${task.priority}\n`;
}
// Add estimated time if available
if (task.estimatedTime) {
content += `**Estimated Time:** ${task.estimatedTime}\n`;
}
// Add description and details
content += '\n## Description\n\n';
content += `${task.description}\n\n`;
if (task.details) {
content += '## Implementation Details\n\n';
content += `${task.details}\n\n`;
}
// Add acceptance criteria if available
if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) {
content += '## Acceptance Criteria\n\n';
task.acceptanceCriteria.forEach((criterion, index) => {
content += `${index + 1}. ${criterion}\n`;
});
content += '\n';
}
// Add subtasks if available
if (task.subtasks && task.subtasks.length > 0) {
content += '## Subtasks\n\n';
task.subtasks.forEach(subtask => {
content += `### ${subtask.title}\n\n`;
content += `**Status:** ${subtask.status || 'pending'}\n`;
if (subtask.description) {
content += `\n${subtask.description}\n\n`;
}
if (subtask.details) {
content += `**Details:** ${subtask.details}\n\n`;
}
});
}
// Add notes if available
if (task.notes) {
content += '## Notes\n\n';
content += `${task.notes}\n\n`;
}
// Add metadata in hidden comment for programmatic access
content += `\n<!-- Task Metadata\n\`\`\`json\n${JSON.stringify(task, null, 2)}\n\`\`\`\n-->\n`;
return content;
}
/**
* Set the status of a task
* @param {string} tasksPath - Path to the tasks.json file
* @param {string} taskIdInput - Task ID(s) to update
* Sanitize a string for use as a filename
* @param {string} name - Input string
* @returns {string} Sanitized string
*/
function sanitizeFilename(name) {
return name
.replace(/[\/\\?%*:|"<>]/g, '-') // Replace invalid chars with dash
.replace(/\s+/g, ' ') // Collapse multiple spaces
.trim(); // Remove leading/trailing spaces
}
/**
* Analyze story elements for plot holes and inconsistencies
* @param {string} elementsPath - Path to the elements JSON file
* @param {Array} elementIds - Optional array of element IDs to analyze
* @returns {Object} Analysis result
*/
async function analyzeForPlotHoles(elementsPath, elementIds = []) {
try {
const storyData = await loadElementsFile(elementsPath);
if (!storyData || !storyData.elements || storyData.elements.length === 0) {
throw new Error(`No elements found in ${elementsPath}`);
}
// Call the AI service to analyze for plot holes
const analysisResult = await aiServices.analyzePlotHoles(storyData, elementIds);
// Save the analysis to a file
const outputPath = path.join(path.dirname(elementsPath), 'plot-analysis.json');
await saveObjectToJsonFile(analysisResult, outputPath);
// Generate a markdown report
await generatePlotAnalysisReport(analysisResult, storyData, path.join(path.dirname(elementsPath), 'plot-analysis.md'));
return {
status: 'success',
message: `Plot hole analysis completed. Found ${analysisResult.plotHoles.length} potential issues.`,
outputPath,
analysisResult
};
} catch (error) {
log('error', `Failed to analyze for plot holes: ${error.message}`);
throw error;
}
}
/**
* Generate a markdown report from the plot hole analysis
* @param {Object} analysis - Plot hole analysis result
* @param {Object} storyData - Original story data
* @param {string} outputPath - Path to save the report
*/
async function generatePlotAnalysisReport(analysis, storyData, outputPath) {
try {
// Create a map of elements by ID for easy reference
const elementsById = new Map(storyData.elements.map(el => [el.id, el]));
// Build the report content
let reportContent = `# Story Plot Analysis Report\n\n`;
reportContent += `*Generated on ${new Date().toLocaleString()}*\n\n`;
// Add character consistency section
reportContent += `## Character Consistency\n\n`;
reportContent += `${analysis.characterConsistency || 'No character consistency analysis available.'}\n\n`;
// Add plot holes section
reportContent += `## Plot Holes and Inconsistencies\n\n`;
if (analysis.plotHoles && analysis.plotHoles.length > 0) {
analysis.plotHoles.forEach((issue, index) => {
reportContent += `### Issue ${index + 1}: ${issue.description}\n\n`;
// Add affected elements
reportContent += `**Affected Elements:**\n\n`;
if (issue.affectedElements && issue.affectedElements.length > 0) {
issue.affectedElements.forEach(elementId => {
const element = elementsById.get(elementId);
if (element) {
reportContent += `- **Element ${elementId}:** ${element.title}\n`;
} else {
reportContent += `- **Element ${elementId}:** (Not found)\n`;
}
});
} else {
reportContent += `- No specific elements identified\n`;
}
reportContent += `\n**Suggested Fix:**\n\n${issue.suggestedFix}\n\n`;
reportContent += `---\n\n`;
});
} else {
reportContent += `*No significant plot holes or inconsistencies found.*\n\n`;
}
// Add general suggestions section
reportContent += `## General Suggestions\n\n`;
if (analysis.suggestions && analysis.suggestions.length > 0) {
analysis.suggestions.forEach((suggestion, index) => {
reportContent += `${index + 1}. ${suggestion}\n`;
});
} else {
reportContent += `*No general suggestions provided.*\n`;
}
// Write the report to file
await fs.writeFile(outputPath, reportContent, 'utf8');
log('info', `Plot analysis report saved to ${outputPath}`);
} catch (error) {
log('error', `Failed to generate plot analysis report: ${error.message}`);
// Don't throw, as this is a non-critical function
}
}
/**
* Refine a story through structured development phases
* @param {string} elementsPath - Path to elements JSON file
* @param {string} conceptPath - Optional path to concept file
* @param {string} phase - Refinement phase (world, characters, plot, themes, all)
* @param {Object} options - Additional options for refinement
* @returns {Object} Refinement result
*/
async function refineStory(elementsPath, conceptPath, phase = 'all', options = {}) {
try {
// Prepare story data from available sources
const storyData = {
elements: null,
concept: null
};
// Load elements if available
if (elementsPath) {
try {
const loadedElements = await loadElementsFile(elementsPath);
if (loadedElements && loadedElements.elements) {
storyData.elements = loadedElements.elements;
log('info', `Loaded ${storyData.elements.length} elements from ${elementsPath}`);
}
} catch (elemError) {
log('warning', `Could not load elements file: ${elemError.message}`);
// Continue even if elements aren't available
}
}
// Load concept if available
if (conceptPath) {
try {
storyData.concept = await fs.readFile(conceptPath, 'utf8');
log('info', `Loaded concept from ${conceptPath}`);
} catch (conceptError) {
log('warning', `Could not load concept file: ${conceptError.message}`);
// Continue even if concept isn't available
}
}
// Ensure we have something to work with
if (!storyData.elements && !storyData.concept) {
throw new Error('No elements or concept file available for refinement');
}
// Check if we're refining all phases
if (phase === 'all') {
// Process all phases in sequence
const phases = ['world', 'characters', 'plot', 'themes'];
let results = {};
for (const currentPhase of phases) {
log('info', `Refining phase: ${currentPhase}`);
// Refine the current phase
const phaseResult = await aiServices.refineStoryPhase(storyData, currentPhase, options);
// Update story data with refined results for next phase
if (phaseResult.refinedConcept) {
storyData.concept = phaseResult.refinedConcept;
}
if (phaseResult.updatedElements) {
storyData.elements = phaseResult.updatedElements;
}
// Store results
results[currentPhase] = phaseResult;
}
// Save final results
if (storyData.elements) {
const saveResult = await saveElements(storyData.elements, elementsPath);
log('info', saveResult.message);
}
if (storyData.concept && conceptPath) {
const refinedConceptPath = path.join(
path.dirname(conceptPath),
`${path.basename(conceptPath, path.extname(conceptPath))}_refined${path.extname(conceptPath)}`
);
await fs.writeFile(refinedConceptPath, storyData.concept, 'utf8');
log('info', `Refined concept saved to ${refinedConceptPath}`);
}
// Generate refinement report
await generateRefinementReport(results, path.join(path.dirname(elementsPath || conceptPath), 'refinement-report.md'));
return {
status: 'success',
message: 'Story refinement completed for all phases',
phases: Object.keys(results)
};
} else {
// Process single phase
const result = await aiServices.refineStoryPhase(storyData, phase, options);
// Save results
if (result.updatedElements && elementsPath) {
const saveResult = await saveElements(result.updatedElements, elementsPath);
log('info', saveResult.message);
}
if (result.refinedConcept && conceptPath) {
const refinedConceptPath = path.join(
path.dirname(conceptPath),
`${path.basename(conceptPath, path.extname(conceptPath))}_refined${path.extname(conceptPath)}`
);
await fs.writeFile(refinedConceptPath, result.refinedConcept, 'utf8');
log('info', `Refined concept saved to ${refinedConceptPath}`);
}
// Generate single phase report
const singlePhaseResults = { [phase]: result };
await generateRefinementReport(singlePhaseResults, path.join(path.dirname(elementsPath || conceptPath), `refinement-${phase}.md`));
return {
status: 'success',
message: `Story refinement completed for phase: ${phase}`,
phase,
result
};
}
} catch (error) {
log('error', `Failed to refine story: ${error.message}`);
throw error;
}
}
/**
* Generate a markdown report from the refinement results
* @param {Object} results - Object containing refinement results for each phase
* @param {string} outputPath - Path to save the report
*/
async function generateRefinementReport(results, outputPath) {
try {
// Build the report content
let reportContent = `# Story Refinement Report\n\n`;
reportContent += `*Generated on ${new Date().toLocaleString()}*\n\n`;
// Process each phase
for (const [phase, result] of Object.entries(results)) {
reportContent += `## ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase Refinement\n\n`;
if (result.summary) {
reportContent += `### Summary\n\n${result.summary}\n\n`;
}
if (result.suggestions && result.suggestions.length > 0) {
reportContent += `### Suggestions\n\n`;
result.suggestions.forEach((suggestion, index) => {
reportContent += `${index + 1}. ${suggestion}\n`;
});
reportContent += `\n`;
}
if (result.updatedElements) {
reportContent += `### Updated Elements\n\n`;
reportContent += `${result.updatedElements.length} elements were updated or created.\n\n`;
}
reportContent += `---\n\n`;
}
// Write the report to file
await fs.writeFile(outputPath, reportContent, 'utf8');
log('info', `Refinement report saved to ${outputPath}`);
} catch (error) {
log('error', `Failed to generate refinement report: ${error.message}`);
// Don't throw, as this is a non-critical function
}
}
// Export task manager functions
export {
parsePRD,
updateTasks,
generateTaskFiles,
setTaskStatus,
updateSingleTaskStatus,
listTasks,
expandTask,
expandAllTasks,
clearSubtasks,
addTask,
addSubtask,
removeSubtask,
findNextTask,
analyzeTaskComplexity,
parseConcept,
generateElementFiles,
generateElementFile,
analyzeForPlotHoles,
refineStory
};