Geoff Hammond 5eafc5ea11
Feat: Added automatic determination of task number based on complexity (#884)
- Added 'defaultNumTasks: 10' to default config, now used in 'parse-prd'
- Adjusted 'parse-prd' and 'expand-task' to:
  - Accept a 'numTasks' value of 0
  - Updated tool and command descriptions
  - Updated prompts to 'an appropriate number of' when value is 0
- Updated 'README-task-master.md' and 'command-reference.md' docs
- Added more tests for: 'parse-prd', 'expand-task' and 'config-manager'

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-07-02 22:12:27 +02:00

432 lines
17 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import chalk from 'chalk';
import boxen from 'boxen';
import { z } from 'zod';
import {
log,
writeJSON,
enableSilentMode,
disableSilentMode,
isSilentMode,
readJSON,
findTaskById,
ensureTagMetadata,
getCurrentTag
} from '../utils.js';
import { generateObjectService } from '../ai-services-unified.js';
import { getDebugFlag } from '../config-manager.js';
import generateTaskFiles from './generate-task-files.js';
import { displayAiUsageSummary } from '../ui.js';
// Define the Zod schema for a SINGLE task object
const prdSingleTaskSchema = z.object({
id: z.number().int().positive(),
title: z.string().min(1),
description: z.string().min(1),
details: z.string().nullable(),
testStrategy: z.string().nullable(),
priority: z.enum(['high', 'medium', 'low']).nullable(),
dependencies: z.array(z.number().int().positive()).nullable(),
status: z.string().nullable()
});
// Define the Zod schema for the ENTIRE expected AI response object
const prdResponseSchema = z.object({
tasks: z.array(prdSingleTaskSchema),
metadata: z.object({
projectName: z.string(),
totalTasks: z.number(),
sourceFile: z.string(),
generatedAt: z.string()
})
});
/**
* 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
* @param {Object} options - Additional options
* @param {boolean} [options.force=false] - Whether to overwrite existing tasks.json.
* @param {boolean} [options.append=false] - Append to existing tasks file.
* @param {boolean} [options.research=false] - Use research model for enhanced PRD analysis.
* @param {Object} [options.reportProgress] - Function to report progress (optional, likely unused).
* @param {Object} [options.mcpLog] - MCP logger object (optional).
* @param {Object} [options.session] - Session object from MCP server (optional).
* @param {string} [options.projectRoot] - Project root path (for MCP/env fallback).
* @param {string} [options.tag] - Target tag for task generation.
* @param {string} [outputFormat='text'] - Output format ('text' or 'json').
*/
async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
const {
reportProgress,
mcpLog,
session,
projectRoot,
force = false,
append = false,
research = false,
tag
} = options;
const isMCP = !!mcpLog;
const outputFormat = isMCP ? 'json' : 'text';
// Use the provided tag, or the current active tag, or default to 'master'
const targetTag = tag || getCurrentTag(projectRoot) || 'master';
const logFn = mcpLog
? mcpLog
: {
// Wrapper for CLI
info: (...args) => log('info', ...args),
warn: (...args) => log('warn', ...args),
error: (...args) => log('error', ...args),
debug: (...args) => log('debug', ...args),
success: (...args) => log('success', ...args)
};
// Create custom reporter using logFn
const report = (message, level = 'info') => {
// Check logFn directly
if (logFn && typeof logFn[level] === 'function') {
logFn[level](message);
} else if (!isSilentMode() && outputFormat === 'text') {
// Fallback to original log only if necessary and in CLI text mode
log(level, message);
}
};
report(
`Parsing PRD file: ${prdPath}, Force: ${force}, Append: ${append}, Research: ${research}`
);
let existingTasks = [];
let nextId = 1;
let aiServiceResponse = null;
try {
// Check if there are existing tasks in the target tag
let hasExistingTasksInTag = false;
if (fs.existsSync(tasksPath)) {
try {
// Read the entire file to check if the tag exists
const existingFileContent = fs.readFileSync(tasksPath, 'utf8');
const allData = JSON.parse(existingFileContent);
// Check if the target tag exists and has tasks
if (
allData[targetTag] &&
Array.isArray(allData[targetTag].tasks) &&
allData[targetTag].tasks.length > 0
) {
hasExistingTasksInTag = true;
existingTasks = allData[targetTag].tasks;
nextId = Math.max(...existingTasks.map((t) => t.id || 0)) + 1;
}
} catch (error) {
// If we can't read the file or parse it, assume no existing tasks in this tag
hasExistingTasksInTag = false;
}
}
// Handle file existence and overwrite/append logic based on target tag
if (hasExistingTasksInTag) {
if (append) {
report(
`Append mode enabled. Found ${existingTasks.length} existing tasks in tag '${targetTag}'. Next ID will be ${nextId}.`,
'info'
);
} else if (!force) {
// Not appending and not forcing overwrite, and there are existing tasks in the target tag
const overwriteError = new Error(
`Tag '${targetTag}' already contains ${existingTasks.length} tasks. Use --force to overwrite or --append to add to existing tasks.`
);
report(overwriteError.message, 'error');
if (outputFormat === 'text') {
console.error(chalk.red(overwriteError.message));
process.exit(1);
} else {
throw overwriteError;
}
} else {
// Force overwrite is true
report(
`Force flag enabled. Overwriting existing tasks in tag '${targetTag}'.`,
'info'
);
}
} else {
// No existing tasks in target tag, proceed without confirmation
report(
`Tag '${targetTag}' is empty or doesn't exist. Creating/updating tag with new tasks.`,
'info'
);
}
report(`Reading PRD content from ${prdPath}`, 'info');
const prdContent = fs.readFileSync(prdPath, 'utf8');
if (!prdContent) {
throw new Error(`Input file ${prdPath} is empty or could not be read.`);
}
// Research-specific enhancements to the system prompt
const researchPromptAddition = research
? `\nBefore breaking down the PRD into tasks, you will:
1. Research and analyze the latest technologies, libraries, frameworks, and best practices that would be appropriate for this project
2. Identify any potential technical challenges, security concerns, or scalability issues not explicitly mentioned in the PRD without discarding any explicit requirements or going overboard with complexity -- always aim to provide the most direct path to implementation, avoiding over-engineering or roundabout approaches
3. Consider current industry standards and evolving trends relevant to this project (this step aims to solve LLM hallucinations and out of date information due to training data cutoff dates)
4. Evaluate alternative implementation approaches and recommend the most efficient path
5. Include specific library versions, helpful APIs, and concrete implementation guidance based on your research
6. Always aim to provide the most direct path to implementation, avoiding over-engineering or roundabout approaches
Your task breakdown should incorporate this research, resulting in more detailed implementation guidance, more accurate dependency mapping, and more precise technology recommendations than would be possible from the PRD text alone, while maintaining all explicit requirements and best practices and all details and nuances of the PRD.`
: '';
// Base system prompt for PRD parsing
const systemPrompt = `You are an AI assistant specialized in analyzing Product Requirements Documents (PRDs) and generating a structured, logically ordered, dependency-aware and sequenced list of development tasks in JSON format.${researchPromptAddition}
Analyze the provided PRD content and generate ${numTasks > 0 ? 'approximately ' + numTasks : 'an appropriate number of'} top-level development tasks. If the complexity or the level of detail of the PRD is high, generate more tasks relative to the complexity of the PRD
Each task should represent a logical unit of work needed to implement the requirements and focus on the most direct and effective way to implement the requirements without unnecessary complexity or overengineering. Include pseudo-code, implementation details, and test strategy for each task. Find the most up to date information to implement each task.
Assign sequential IDs starting from ${nextId}. Infer title, description, details, and test strategy for each task based *only* on the PRD content.
Set status to 'pending', dependencies to an empty array [], and priority to 'medium' initially for all tasks.
Respond ONLY with a valid JSON object containing a single key "tasks", where the value is an array of task objects adhering to the provided Zod schema. Do not include any explanation or markdown formatting.
Each task should follow this JSON structure:
{
"id": number,
"title": string,
"description": string,
"status": "pending",
"dependencies": number[] (IDs of tasks this depends on),
"priority": "high" | "medium" | "low",
"details": string (implementation details),
"testStrategy": string (validation approach)
}
Guidelines:
1. ${numTasks > 0 ? 'Unless complexity warrants otherwise' : 'Depending on the complexity'}, create ${numTasks > 0 ? 'exactly ' + numTasks : 'an appropriate number of'} tasks, numbered sequentially starting from ${nextId}
2. Each task should be atomic and focused on a single responsibility following the most up to date best practices and standards
3. Order tasks logically - consider dependencies and implementation sequence
4. Early tasks should focus on setup, core functionality first, then advanced features
5. Include clear validation/testing approach for each task
6. Set appropriate dependency IDs (a task can only depend on tasks with lower IDs, potentially including existing tasks with IDs less than ${nextId} if applicable)
7. Assign priority (high/medium/low) based on criticality and dependency order
8. Include detailed implementation guidance in the "details" field${research ? ', with specific libraries and version recommendations based on your research' : ''}
9. If the PRD contains specific requirements for libraries, database schemas, frameworks, tech stacks, or any other implementation details, STRICTLY ADHERE to these requirements in your task breakdown and do not discard them under any circumstance
10. Focus on filling in any gaps left by the PRD or areas that aren't fully specified, while preserving all explicit requirements
11. Always aim to provide the most direct path to implementation, avoiding over-engineering or roundabout approaches${research ? '\n12. For each task, include specific, actionable guidance based on current industry standards and best practices discovered through research' : ''}`;
// Build user prompt with PRD content
const userPrompt = `Here's the Product Requirements Document (PRD) to break down into approximately ${numTasks > 0 ? 'approximately ' + numTasks : 'an appropriate number of'} tasks, starting IDs from ${nextId}:${research ? '\n\nRemember to thoroughly research current best practices and technologies before task breakdown to provide specific, actionable implementation details.' : ''}\n\n${prdContent}\n\n
Return your response in this format:
{
"tasks": [
{
"id": 1,
"title": "Setup Project Repository",
"description": "...",
...
},
...
],
"metadata": {
"projectName": "PRD Implementation",
"totalTasks": {number of tasks},
"sourceFile": "${prdPath}",
"generatedAt": "YYYY-MM-DD"
}
}`;
// Call the unified AI service
report(
`Calling AI service to generate tasks from PRD${research ? ' with research-backed analysis' : ''}...`,
'info'
);
// Call generateObjectService with the CORRECT schema and additional telemetry params
aiServiceResponse = await generateObjectService({
role: research ? 'research' : 'main', // Use research role if flag is set
session: session,
projectRoot: projectRoot,
schema: prdResponseSchema,
objectName: 'tasks_data',
systemPrompt: systemPrompt,
prompt: userPrompt,
commandName: 'parse-prd',
outputType: isMCP ? 'mcp' : 'cli'
});
// Create the directory if it doesn't exist
const tasksDir = path.dirname(tasksPath);
if (!fs.existsSync(tasksDir)) {
fs.mkdirSync(tasksDir, { recursive: true });
}
logFn.success(
`Successfully parsed PRD via AI service${research ? ' with research-backed analysis' : ''}.`
);
// Validate and Process Tasks
// const generatedData = aiServiceResponse?.mainResult?.object;
// Robustly get the actual AI-generated object
let generatedData = null;
if (aiServiceResponse?.mainResult) {
if (
typeof aiServiceResponse.mainResult === 'object' &&
aiServiceResponse.mainResult !== null &&
'tasks' in aiServiceResponse.mainResult
) {
// If mainResult itself is the object with a 'tasks' property
generatedData = aiServiceResponse.mainResult;
} else if (
typeof aiServiceResponse.mainResult.object === 'object' &&
aiServiceResponse.mainResult.object !== null &&
'tasks' in aiServiceResponse.mainResult.object
) {
// If mainResult.object is the object with a 'tasks' property
generatedData = aiServiceResponse.mainResult.object;
}
}
if (!generatedData || !Array.isArray(generatedData.tasks)) {
logFn.error(
`Internal Error: generateObjectService returned unexpected data structure: ${JSON.stringify(generatedData)}`
);
throw new Error(
'AI service returned unexpected data structure after validation.'
);
}
let currentId = nextId;
const taskMap = new Map();
const processedNewTasks = generatedData.tasks.map((task) => {
const newId = currentId++;
taskMap.set(task.id, newId);
return {
...task,
id: newId,
status: 'pending',
priority: task.priority || 'medium',
dependencies: Array.isArray(task.dependencies) ? task.dependencies : [],
subtasks: []
};
});
// Remap dependencies for the NEWLY processed tasks
processedNewTasks.forEach((task) => {
task.dependencies = task.dependencies
.map((depId) => taskMap.get(depId)) // Map old AI ID to new sequential ID
.filter(
(newDepId) =>
newDepId != null && // Must exist
newDepId < task.id && // Must be a lower ID (could be existing or newly generated)
(findTaskById(existingTasks, newDepId) || // Check if it exists in old tasks OR
processedNewTasks.some((t) => t.id === newDepId)) // check if it exists in new tasks
);
});
const finalTasks = append
? [...existingTasks, ...processedNewTasks]
: processedNewTasks;
// Read the existing file to preserve other tags
let outputData = {};
if (fs.existsSync(tasksPath)) {
try {
const existingFileContent = fs.readFileSync(tasksPath, 'utf8');
outputData = JSON.parse(existingFileContent);
} catch (error) {
// If we can't read the existing file, start with empty object
outputData = {};
}
}
// Update only the target tag, preserving other tags
outputData[targetTag] = {
tasks: finalTasks,
metadata: {
created:
outputData[targetTag]?.metadata?.created || new Date().toISOString(),
updated: new Date().toISOString(),
description: `Tasks for ${targetTag} context`
}
};
// Ensure the target tag has proper metadata
ensureTagMetadata(outputData[targetTag], {
description: `Tasks for ${targetTag} context`
});
// Write the complete data structure back to the file
fs.writeFileSync(tasksPath, JSON.stringify(outputData, null, 2));
report(
`Successfully ${append ? 'appended' : 'generated'} ${processedNewTasks.length} tasks in ${tasksPath}${research ? ' with research-backed analysis' : ''}`,
'success'
);
// Generate markdown task files after writing tasks.json
// await generateTaskFiles(tasksPath, path.dirname(tasksPath), { mcpLog });
// Handle CLI output (e.g., success message)
if (outputFormat === 'text') {
console.log(
boxen(
chalk.green(
`Successfully generated ${processedNewTasks.length} new tasks${research ? ' with research-backed analysis' : ''}. Total tasks in ${tasksPath}: ${finalTasks.length}`
),
{ 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 }
}
)
);
if (aiServiceResponse && aiServiceResponse.telemetryData) {
displayAiUsageSummary(aiServiceResponse.telemetryData, 'cli');
}
}
// Return telemetry data
return {
success: true,
tasksPath,
telemetryData: aiServiceResponse?.telemetryData,
tagInfo: aiServiceResponse?.tagInfo
};
} catch (error) {
report(`Error parsing PRD: ${error.message}`, 'error');
// Only show error UI for text output (CLI)
if (outputFormat === 'text') {
console.error(chalk.red(`Error: ${error.message}`));
if (getDebugFlag(projectRoot)) {
// Use projectRoot for debug flag check
console.error(error);
}
process.exit(1);
} else {
throw error; // Re-throw for JSON output
}
}
}
export default parsePRD;