Add list command with subtasks option and update documentation

This commit is contained in:
Eyal Toledano
2025-03-04 20:35:30 -05:00
parent 7a33979a62
commit aed8f5b3a0
6 changed files with 629 additions and 282 deletions

View File

@@ -42,24 +42,43 @@
import fs from 'fs';
import path from 'path';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import readline from 'readline';
import { program } from 'commander';
import chalk from 'chalk';
import { Anthropic } from '@anthropic-ai/sdk';
import OpenAI from 'openai';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables from .env file
// Load environment variables
dotenv.config();
import Anthropic from '@anthropic-ai/sdk';
// Configure Anthropic client
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Configure OpenAI client for Perplexity
const perplexity = new OpenAI({
apiKey: process.env.PERPLEXITY_API_KEY,
baseURL: 'https://api.perplexity.ai',
});
// Model configuration
const MODEL = process.env.MODEL || 'claude-3-7-sonnet-20250219';
const PERPLEXITY_MODEL = process.env.PERPLEXITY_MODEL || 'sonar-small-online';
const MAX_TOKENS = parseInt(process.env.MAX_TOKENS || '4000');
const TEMPERATURE = parseFloat(process.env.TEMPERATURE || '0.7');
// Set up configuration with environment variables or defaults
const CONFIG = {
model: process.env.MODEL || "claude-3-7-sonnet-20250219",
maxTokens: parseInt(process.env.MAX_TOKENS || "4000"),
temperature: parseFloat(process.env.TEMPERATURE || "0.7"),
model: MODEL,
maxTokens: MAX_TOKENS,
temperature: TEMPERATURE,
debug: process.env.DEBUG === "true",
logLevel: process.env.LOG_LEVEL || "info",
defaultSubtasks: parseInt(process.env.DEFAULT_SUBTASKS || "3"),
@@ -95,10 +114,6 @@ function log(level, ...args) {
}
}
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
function readJSON(filepath) {
if (!fs.existsSync(filepath)) return null;
const content = fs.readFileSync(filepath, 'utf8');
@@ -613,7 +628,7 @@ function setTaskStatus(tasksPath, taskIdInput, newStatus) {
//
// 5) list tasks
//
function listTasks(tasksPath) {
function listTasks(tasksPath, statusFilter, withSubtasks = false) {
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
log('error', "No valid tasks found.");
@@ -621,114 +636,138 @@ function listTasks(tasksPath) {
}
log('info', `Tasks in ${tasksPath}:`);
data.tasks.forEach(t => {
// Filter tasks by status if a filter is provided
const filteredTasks = statusFilter
? data.tasks.filter(t => t.status === statusFilter)
: data.tasks;
filteredTasks.forEach(t => {
log('info', `- ID=${t.id}, [${t.status}] ${t.title}`);
// Display subtasks if requested and they exist
if (withSubtasks && t.subtasks && t.subtasks.length > 0) {
t.subtasks.forEach(st => {
log('info', ` └─ ID=${t.id}.${st.id}, [${st.status || 'pending'}] ${st.title}`);
});
}
});
// If no tasks match the filter, show a message
if (filteredTasks.length === 0) {
log('info', `No tasks found${statusFilter ? ` with status '${statusFilter}'` : ''}.`);
}
}
//
// 6) expand task with subtasks
//
async function expandTask(tasksPath, taskId, numSubtasks, additionalContext = '') {
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
log('error', "No valid tasks found.");
process.exit(1);
/**
* Expand a task by generating subtasks
* @param {string} taskId - The ID of the task to expand
* @param {number} numSubtasks - The number of subtasks to generate
* @param {boolean} useResearch - Whether to use Perplexity for research-backed subtask generation
* @returns {Promise<void>}
*/
async function expandTask(taskId, numSubtasks = CONFIG.defaultSubtasks, useResearch = false) {
try {
// Get the tasks
const tasksData = readJSON(path.join(process.cwd(), 'tasks', 'tasks.json'));
const task = tasksData.tasks.find(t => t.id === parseInt(taskId));
if (!task) {
console.error(chalk.red(`Task with ID ${taskId} not found.`));
return;
}
// Check if the task is already completed
if (task.status === 'completed' || task.status === 'done') {
console.log(chalk.yellow(`Task ${taskId} is already completed. Skipping expansion.`));
return;
}
// Initialize subtasks array if it doesn't exist
if (!task.subtasks) {
task.subtasks = [];
}
// Calculate the next subtask ID
const nextSubtaskId = task.subtasks.length > 0
? Math.max(...task.subtasks.map(st => st.id)) + 1
: 1;
// Generate subtasks
let subtasks;
if (useResearch) {
console.log(chalk.blue(`Using Perplexity AI for research-backed subtask generation...`));
subtasks = await generateSubtasksWithPerplexity(task, numSubtasks, nextSubtaskId);
} else {
subtasks = await generateSubtasks(task, numSubtasks, nextSubtaskId);
}
// Add the subtasks to the task
task.subtasks = [...task.subtasks, ...subtasks];
// Save the updated tasks
fs.writeFileSync(
path.join(process.cwd(), 'tasks', 'tasks.json'),
JSON.stringify(tasksData, null, 2)
);
console.log(chalk.green(`Added ${subtasks.length} subtasks to task ${taskId}.`));
// Log the added subtasks
subtasks.forEach(st => {
console.log(chalk.cyan(` ${st.id}. ${st.title}`));
console.log(chalk.gray(` ${st.description.substring(0, 100)}${st.description.length > 100 ? '...' : ''}`));
});
} catch (error) {
console.error(chalk.red('Error expanding task:'), error);
}
// Use default subtasks count from config if not specified
numSubtasks = numSubtasks || CONFIG.defaultSubtasks;
const task = data.tasks.find(t => t.id === taskId);
if (!task) {
log('error', `Task with ID=${taskId} not found.`);
process.exit(1);
}
// Skip tasks that are already completed
if (task.status === 'done' || task.status === 'completed') {
log('info', `Skipping task ID=${taskId} "${task.title}" - task is already marked as ${task.status}.`);
log('info', `Use set-status command to change the status if you want to modify this task.`);
return false;
}
log('info', `Expanding task: ${task.title}`);
// Initialize subtasks array if it doesn't exist
if (!task.subtasks) {
task.subtasks = [];
}
// Calculate next subtask ID
const nextSubtaskId = task.subtasks.length > 0
? Math.max(...task.subtasks.map(st => st.id)) + 1
: 1;
// Generate subtasks using Claude
const subtasks = await generateSubtasks(task, numSubtasks, nextSubtaskId, additionalContext);
// Add new subtasks to the task
task.subtasks = [...task.subtasks, ...subtasks];
// Update tasks.json
writeJSON(tasksPath, data);
log('info', `Added ${subtasks.length} subtasks to task ID=${taskId}.`);
// Print the new subtasks
log('info', "New subtasks:");
subtasks.forEach(st => {
log('info', `- ${st.id}. ${st.title}`);
});
return true;
}
//
// Expand all tasks with subtasks
//
async function expandAllTasks(tasksPath, numSubtasks, additionalContext = '', forceRegenerate = false) {
const data = readJSON(tasksPath);
if (!data || !data.tasks) {
log('error', "No valid tasks found.");
process.exit(1);
}
log('info', `Expanding all ${data.tasks.length} tasks with subtasks...`);
let tasksExpanded = 0;
let tasksSkipped = 0;
let tasksCompleted = 0;
// Process each task sequentially to avoid overwhelming the API
for (const task of data.tasks) {
// Skip tasks that are already completed
if (task.status === 'done' || task.status === 'completed') {
log('info', `Skipping task ID=${task.id} "${task.title}" - task is already marked as ${task.status}.`);
tasksCompleted++;
continue;
/**
* Expand all tasks that are not completed
* @param {number} numSubtasks - The number of subtasks to generate for each task
* @param {boolean} useResearch - Whether to use Perplexity for research-backed subtask generation
* @returns {Promise<number>} - The number of tasks expanded
*/
async function expandAllTasks(numSubtasks = CONFIG.defaultSubtasks, useResearch = false) {
try {
// Get the tasks
const tasksData = readJSON(path.join(process.cwd(), 'tasks', 'tasks.json'));
if (!tasksData || !tasksData.tasks || !Array.isArray(tasksData.tasks)) {
console.error(chalk.red('No valid tasks found.'));
return 0;
}
// Skip tasks that already have subtasks unless force regeneration is enabled
if (!forceRegenerate && task.subtasks && task.subtasks.length > 0) {
log('info', `Skipping task ID=${task.id} "${task.title}" - already has ${task.subtasks.length} subtasks`);
tasksSkipped++;
continue;
// Filter tasks that are not completed
const tasksToExpand = tasksData.tasks.filter(task =>
task.status !== 'completed' && task.status !== 'done'
);
if (tasksToExpand.length === 0) {
console.log(chalk.yellow('No tasks to expand. All tasks are already completed.'));
return 0;
}
const success = await expandTask(tasksPath, task.id, numSubtasks, additionalContext);
if (success) {
console.log(chalk.blue(`Expanding ${tasksToExpand.length} tasks with ${numSubtasks} subtasks each...`));
let tasksExpanded = 0;
// Expand each task
for (const task of tasksToExpand) {
console.log(chalk.blue(`\nExpanding task ${task.id}: ${task.title}`));
await expandTask(task.id, numSubtasks, useResearch);
tasksExpanded++;
}
}
log('info', `Expansion complete: ${tasksExpanded} tasks expanded, ${tasksSkipped} tasks skipped (already had subtasks), ${tasksCompleted} tasks skipped (already completed).`);
if (tasksSkipped > 0) {
log('info', `Tip: Use --force flag to regenerate subtasks for all tasks, including those that already have subtasks.`);
}
if (tasksCompleted > 0) {
log('info', `Note: Completed tasks are always skipped. Use set-status command to change task status if needed.`);
console.log(chalk.green(`\nExpanded ${tasksExpanded} tasks with ${numSubtasks} subtasks each.`));
return tasksExpanded;
} catch (error) {
console.error(chalk.red('Error expanding all tasks:'), error);
return 0;
}
}
@@ -961,135 +1000,272 @@ function parseSubtasksFromText(text, startId, expectedCount) {
return subtasks;
}
/**
* Generate subtasks for a task using Perplexity AI with research capabilities
* @param {Object} task - The task to generate subtasks for
* @param {number} numSubtasks - The number of subtasks to generate
* @param {number} nextSubtaskId - The ID to start assigning to subtasks
* @returns {Promise<Array>} - The generated subtasks
*/
async function generateSubtasksWithPerplexity(task, numSubtasks = 3, nextSubtaskId = 1) {
const { title, description, details = '', subtasks = [] } = task;
console.log(chalk.blue(`Generating ${numSubtasks} subtasks for task: ${title}`));
if (subtasks.length > 0) {
console.log(chalk.yellow(`Task already has ${subtasks.length} subtasks. Adding ${numSubtasks} more.`));
}
// Get the tasks.json content for context
let tasksData = {};
try {
tasksData = readJSON(path.join(process.cwd(), 'tasks', 'tasks.json'));
} catch (error) {
console.log(chalk.yellow('Could not read tasks.json for context. Proceeding without it.'));
}
// Get the PRD content for context if available
let prdContent = '';
if (tasksData.meta && tasksData.meta.source) {
try {
prdContent = fs.readFileSync(path.join(process.cwd(), tasksData.meta.source), 'utf8');
} catch (error) {
console.log(chalk.yellow(`Could not read PRD at ${tasksData.meta.source}. Proceeding without it.`));
}
}
// Construct the prompt for Perplexity/Anthropic
const prompt = `I need to break down the following task into ${numSubtasks} detailed subtasks:
Task Title: ${title}
Task Description: ${description}
Additional Details: ${details}
${subtasks.length > 0 ? `Existing Subtasks:
${subtasks.map(st => `- ${st.title}: ${st.description}`).join('\n')}` : ''}
${prdContent ? `Here is the Product Requirements Document for context:
${prdContent}` : ''}
${tasksData.tasks ? `Here are the other tasks in the project for context:
${JSON.stringify(tasksData.tasks.filter(t => t.id !== task.id).map(t => ({ id: t.id, title: t.title, description: t.description })), null, 2)}` : ''}
Please generate ${numSubtasks} subtasks. For each subtask, provide:
1. A clear, concise title
2. A detailed description explaining what needs to be done
3. Dependencies (if any) - list the IDs of tasks this subtask depends on
4. Acceptance criteria - specific conditions that must be met for the subtask to be considered complete
Format each subtask as follows:
Subtask 1: [Title]
Description: [Detailed description]
Dependencies: [List of task IDs, or "None" if no dependencies]
Acceptance Criteria: [List of criteria]
Subtask 2: [Title]
...
Research the task thoroughly and ensure the subtasks are comprehensive, specific, and actionable.`;
// Start loading indicator
const loadingInterval = startLoadingIndicator('Researching and generating subtasks with AI');
try {
let responseText;
try {
// Try to use Perplexity first
console.log(chalk.blue('Using Perplexity AI for research-backed subtask generation...'));
const result = await perplexity.chat.completions.create({
model: PERPLEXITY_MODEL,
messages: [{
role: "user",
content: prompt
}],
temperature: TEMPERATURE,
max_tokens: MAX_TOKENS,
});
// Extract the response text
responseText = result.choices[0].message.content;
console.log(chalk.green('Successfully generated subtasks with Perplexity AI'));
} catch (perplexityError) {
console.log(chalk.yellow('Falling back to Anthropic for subtask generation...'));
console.log(chalk.gray('Perplexity error:'), perplexityError.message);
// Use Anthropic as fallback
const stream = await anthropic.messages.create({
model: MODEL,
max_tokens: MAX_TOKENS,
temperature: TEMPERATURE,
system: "You are an expert software developer and project manager. Your task is to break down software development tasks into detailed subtasks.",
messages: [
{
role: "user",
content: prompt
}
],
stream: true
});
// Process the stream
responseText = '';
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
responseText += chunk.delta.text;
}
}
console.log(chalk.green('Successfully generated subtasks with Anthropic AI'));
}
// Stop loading indicator
stopLoadingIndicator(loadingInterval);
if (CONFIG.debug) {
console.log(chalk.gray('AI Response:'));
console.log(chalk.gray(responseText));
}
// Parse the subtasks from the response text
const subtasks = parseSubtasksFromText(responseText, nextSubtaskId, numSubtasks);
return subtasks;
} catch (error) {
stopLoadingIndicator(loadingInterval);
console.error(chalk.red('Error generating subtasks:'), error);
throw error;
}
}
// ------------------------------------------
// Main CLI
// ------------------------------------------
(async function main() {
const args = process.argv.slice(2);
const command = args[0];
async function main() {
program
.name('dev')
.description('AI-driven development task management')
.version('1.3.1');
const outputDir = path.resolve(process.cwd(), 'tasks');
// Update tasksPath to be inside the tasks directory
const tasksPath = path.resolve(outputDir, 'tasks.json');
program
.command('parse-prd')
.description('Parse a PRD file and generate tasks')
.argument('<file>', 'Path to the PRD file')
.option('-o, --output <file>', 'Output file path', 'tasks/tasks.json')
.option('-n, --num-tasks <number>', 'Number of tasks to generate', '10')
.action(async (file, options) => {
const numTasks = parseInt(options.numTasks, 10);
const outputPath = options.output;
console.log(chalk.blue(`Parsing PRD file: ${file}`));
console.log(chalk.blue(`Generating ${numTasks} tasks...`));
await parsePRD(file, outputPath, numTasks);
});
const inputArg = (args.find(a => a.startsWith('--input=')) || '').split('=')[1] || 'sample-prd.txt';
const fromArg = (args.find(a => a.startsWith('--from=')) || '').split('=')[1];
const promptArg = (args.find(a => a.startsWith('--prompt=')) || '').split('=')[1] || '';
const idArg = (args.find(a => a.startsWith('--id=')) || '').split('=')[1];
const statusArg = (args.find(a => a.startsWith('--status=')) || '').split('=')[1] || '';
const tasksCountArg = (args.find(a => a.startsWith('--tasks=')) || '').split('=')[1];
const numTasks = tasksCountArg ? parseInt(tasksCountArg, 10) : undefined;
const subtasksArg = (args.find(a => a.startsWith('--subtasks=')) || '').split('=')[1];
const numSubtasks = subtasksArg ? parseInt(subtasksArg, 10) : 3; // Default to 3 subtasks if not specified
const forceFlag = args.includes('--force'); // Check if --force flag is present
program
.command('update')
.description('Update tasks based on the PRD')
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.action(async (options) => {
const tasksPath = options.file;
console.log(chalk.blue(`Updating tasks from: ${tasksPath}`));
await updateTasks(tasksPath);
});
log('info', `Executing command: ${command}`);
// Make sure the tasks directory exists
if (!fs.existsSync(outputDir)) {
log('info', `Creating tasks directory: ${outputDir}`);
fs.mkdirSync(outputDir, { recursive: true });
}
switch (command) {
case 'parse-prd':
log('info', `Parsing PRD from ${inputArg} to generate tasks.json...`);
if (numTasks) {
log('info', `Limiting to ${numTasks} tasks as specified`);
program
.command('generate')
.description('Generate task files from tasks.json')
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.option('-o, --output <dir>', 'Output directory', 'tasks')
.action(async (options) => {
const tasksPath = options.file;
const outputDir = options.output;
console.log(chalk.blue(`Generating task files from: ${tasksPath}`));
console.log(chalk.blue(`Output directory: ${outputDir}`));
await generateTaskFiles(tasksPath, outputDir);
});
program
.command('set-status')
.description('Set the status of a task')
.argument('<id>', 'Task ID')
.argument('<status>', 'New status (todo, in-progress, review, done)')
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.action(async (id, status, options) => {
const tasksPath = options.file;
const taskId = parseInt(id, 10);
console.log(chalk.blue(`Setting status of task ${taskId} to: ${status}`));
await setTaskStatus(tasksPath, taskId, status);
});
program
.command('list')
.description('List all tasks')
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.option('-s, --status <status>', 'Filter by status')
.option('--with-subtasks', 'Show subtasks for each task')
.action(async (options) => {
const tasksPath = options.file;
const statusFilter = options.status;
const withSubtasks = options.withSubtasks || false;
console.log(chalk.blue(`Listing tasks from: ${tasksPath}`));
if (statusFilter) {
console.log(chalk.blue(`Filtering by status: ${statusFilter}`));
}
await parsePRD(inputArg, tasksPath, numTasks);
break;
case 'update':
if (!fromArg) {
log('error', "Please specify --from=<id>. e.g. node dev.js update --from=3 --prompt='Changes...'");
process.exit(1);
if (withSubtasks) {
console.log(chalk.blue('Including subtasks in listing'));
}
log('info', `Updating tasks from ID ${fromArg} based on prompt...`);
await updateTasks(tasksPath, parseInt(fromArg, 10), promptArg);
break;
await listTasks(tasksPath, statusFilter, withSubtasks);
});
case 'generate':
log('info', `Generating individual task files from ${tasksPath} to ${outputDir}...`);
generateTaskFiles(tasksPath, outputDir);
break;
case 'set-status':
if (!idArg) {
log('error', "Missing --id=<taskId> argument.");
process.exit(1);
}
if (!statusArg) {
log('error', "Missing --status=<newStatus> argument (e.g., done, pending, deferred, in-progress).");
process.exit(1);
}
log('info', `Setting task(s) ${idArg} status to "${statusArg}"...`);
setTaskStatus(tasksPath, idArg, statusArg);
break;
case 'list':
log('info', `Listing tasks from ${tasksPath}...`);
listTasks(tasksPath);
break;
case 'expand':
if (args.includes('--all')) {
// Expand all tasks
log('info', `Expanding all tasks with ${numSubtasks} subtasks each...`);
await expandAllTasks(tasksPath, numSubtasks, promptArg, forceFlag);
program
.command('expand')
.description('Expand tasks with subtasks')
.option('-f, --file <file>', 'Path to the tasks file', 'tasks/tasks.json')
.option('-i, --id <id>', 'Task ID to expand')
.option('-a, --all', 'Expand all tasks')
.option('-n, --num <number>', 'Number of subtasks to generate', CONFIG.defaultSubtasks.toString())
.option('-r, --research', 'Use Perplexity AI for research-backed subtask generation')
.option('--force', 'Force regeneration of subtasks for tasks that already have them')
.action(async (options) => {
const tasksPath = options.file;
const idArg = options.id ? parseInt(options.id, 10) : null;
const allFlag = options.all;
const numSubtasks = parseInt(options.num, 10);
const forceFlag = options.force;
const useResearch = options.research;
if (allFlag) {
console.log(chalk.blue(`Expanding all tasks with ${numSubtasks} subtasks each...`));
if (useResearch) {
console.log(chalk.blue('Using Perplexity AI for research-backed subtask generation'));
}
await expandAllTasks(numSubtasks, useResearch);
} else if (idArg) {
// Expand a specific task
log('info', `Expanding task ${idArg} with ${numSubtasks} subtasks...`);
await expandTask(tasksPath, parseInt(idArg, 10), numSubtasks, promptArg);
console.log(chalk.blue(`Expanding task ${idArg} with ${numSubtasks} subtasks...`));
if (useResearch) {
console.log(chalk.blue('Using Perplexity AI for research-backed subtask generation'));
}
await expandTask(idArg, numSubtasks, useResearch);
} else {
log('error', "Error: Please specify a task ID with --id=<id> or use --all to expand all tasks.");
process.exit(1);
console.error(chalk.red('Error: Please specify a task ID with --id=<id> or use --all to expand all tasks.'));
}
break;
});
default:
log('info', `
Dev.js - Task Management Script
await program.parseAsync(process.argv);
}
Subcommands:
1) parse-prd --input=some-prd.txt [--tasks=10]
-> Creates/overwrites tasks.json with a set of tasks.
-> Optional --tasks parameter limits the number of tasks generated.
// ... existing code ...
2) update --from=5 --prompt="We changed from Slack to Discord."
-> Regenerates tasks from ID >= 5 using the provided prompt.
3) generate
-> Generates per-task files (e.g., task_001.txt) from tasks.json
4) set-status --id=4 --status=done
-> Updates a single task's status to done (or pending, deferred, in-progress, etc.).
-> Supports comma-separated IDs for updating multiple tasks: --id=1,2,3,1.1,1.2
5) list
-> Lists tasks in a brief console view (ID, title, status).
6) expand --id=3 --subtasks=5 [--prompt="Additional context"]
-> Expands a task with subtasks for more detailed implementation.
-> Use --all instead of --id to expand all tasks.
-> Optional --subtasks parameter controls number of subtasks (default: 3).
-> Add --force when using --all to regenerate subtasks for tasks that already have them.
-> Note: Tasks marked as 'done' or 'completed' are always skipped.
Usage examples:
node dev.js parse-prd --input=scripts/prd.txt
node dev.js parse-prd --input=scripts/prd.txt --tasks=10
node dev.js update --from=4 --prompt="Refactor tasks from ID 4 onward"
node dev.js generate
node dev.js set-status --id=3 --status=done
node dev.js list
node dev.js expand --id=3 --subtasks=5
node dev.js expand --all
node dev.js expand --all --force
`);
break;
}
})().catch(err => {
main().catch(err => {
log('error', err);
process.exit(1);
});