mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-11-22 04:58:46 +00:00
* refactor(context): Standardize tag and projectRoot handling across all task tools This commit unifies context management by adopting a boundary-first resolution strategy. All task-scoped tools now resolve `tag` and `projectRoot` at their entry point and forward these values to the underlying direct functions. This approach centralizes context logic, ensuring consistent behavior and enhanced flexibility in multi-tag environments. * fix(tag): Clean up tag handling in task functions and sync process This commit refines the handling of the `tag` parameter across multiple functions, ensuring consistent context management. The `tag` is now passed more efficiently in `listTasksDirect`, `setTaskStatusDirect`, and `syncTasksToReadme`, improving clarity and reducing redundancy. Additionally, a TODO comment has been added in `sync-readme.js` to address future tag support enhancements. * feat(tag): Implement Boundary-First Tag Resolution for consistent tag handling This commit introduces Boundary-First Tag Resolution in the task manager, ensuring consistent and deterministic tag handling across CLI and MCP. This change resolves potential race conditions and improves the reliability of tag-specific operations. Additionally, the `expandTask` function has been updated to use the resolved tag when writing JSON, enhancing data integrity during task updates. * chore(biome): formatting * fix(expand-task): Update writeJSON call to use tag instead of resolvedTag * fix(commands): Enhance complexity report path resolution and task initialization `resolveComplexityReportPath` function to streamline output path generation based on tag context and user-defined output. - Improved clarity and maintainability of command handling by centralizing path resolution logic. * Fix: unknown currentTag * fix(task-manager): Update generateTaskFiles calls to include tag and projectRoot parameters This commit modifies the `moveTask` and `updateSubtaskById` functions to pass the `tag` and `projectRoot` parameters to the `generateTaskFiles` function. This ensures that task files are generated with the correct context when requested, enhancing consistency in task management operations. * fix(commands): Refactor tag handling and complexity report path resolution This commit updates the `registerCommands` function to utilize `taskMaster.getCurrentTag()` for consistent tag retrieval across command actions. It also enhances the initialization of `TaskMaster` by passing the tag directly, improving clarity and maintainability. The complexity report path resolution is streamlined to ensure correct file naming based on the current tag context. * fix(task-master): Update complexity report path expectations in tests This commit modifies the `initTaskMaster` test to expect a valid string for the complexity report path, ensuring it matches the expected file naming convention. This change enhances test reliability by verifying the correct output format when the path is generated. * fix(set-task-status): Enhance logging and tag resolution in task status updates This commit improves the logging output in the `registerSetTaskStatusTool` function to include the tag context when setting task statuses. It also updates the tag handling by resolving the tag using the `resolveTag` utility, ensuring that the correct tag is used when updating task statuses. Additionally, the `setTaskStatus` function is modified to remove the tag parameter from the `readJSON` and `writeJSON` calls, streamlining the data handling process. * fix(commands, expand-task, task-manager): Add complexity report option and enhance path handling This commit introduces a new `--complexity-report` option in the `registerCommands` function, allowing users to specify a custom path for the complexity report. The `expandTask` function is updated to accept the `complexityReportPath` from the context, ensuring it is utilized correctly during task expansion. Additionally, the `setTaskStatus` function now includes the `tag` parameter in the `readJSON` and `writeJSON` calls, improving task status updates with proper context. The `initTaskMaster` function is also modified to create parent directories for output paths, enhancing file handling robustness. * fix(expand-task): Add complexityReportPath to context for task expansion tests This commit updates the test for the `expandTask` function by adding the `complexityReportPath` to the context object. This change ensures that the complexity report path is correctly utilized in the test, aligning with recent enhancements to complexity report handling in the task manager. * chore: implement suggested changes * fix(parse-prd): Clarify tag parameter description for task organization Updated the documentation for the `tag` parameter in the `parse-prd.js` file to provide a clearer context on its purpose for organizing tasks into separate task lists. * Fix Inconsistent tag resolution pattern. * fix: Enhance complexity report path handling with tag support This commit updates various functions to incorporate the `tag` parameter when resolving complexity report paths. The `expandTaskDirect`, `resolveComplexityReportPath`, and related tools now utilize the current tag context, improving consistency in task management. Additionally, the complexity report path is now correctly passed through the context in the `expand-task` and `set-task-status` tools, ensuring accurate report retrieval based on the active tag. * Updated the JSDoc for the `tag` parameter in the `show-task.js` file. * Remove redundant comment on tag parameter in readJSON call * Remove unused import for getTagAwareFilePath * Add missed complexityReportPath to args for task expansion * fix(tests): Enhance research tests with tag-aware functionality This commit updates the `research.test.js` file to improve the testing of the `performResearch` function by incorporating tag-aware functionality. Key changes include mocking the `findProjectRoot` to return a valid path, enhancing the `ContextGatherer` and `FuzzyTaskSearch` mocks, and adding comprehensive tests for tag parameter handling in various scenarios. The tests now cover passing different tag values, ensuring correct behavior when tags are provided, undefined, or null, and validating the integration of tags in task discovery and context gathering processes. * Remove unused import for * fix: Refactor complexity report path handling and improve argument destructuring This commit enhances the `expandTaskDirect` function by improving the destructuring of arguments for better readability. It also updates the `analyze.js` and `analyze-task-complexity.js` files to utilize the new `resolveComplexityReportOutputPath` function, ensuring tag-aware resolution of output paths. Additionally, logging has been added to provide clarity on the report path being used. * test: Add complexity report tag isolation tests and improve path handling This commit introduces a new test file for complexity report tag isolation, ensuring that different tags maintain separate complexity reports. It enhances the existing tests in `analyze-task-complexity.test.js` by updating expectations to use `expect.stringContaining` for file paths, improving robustness against path changes. The new tests cover various scenarios, including path resolution and report generation for both master and feature tags, ensuring no cross-tag contamination occurs. * Update scripts/modules/task-manager/list-tasks.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update scripts/modules/task-manager/list-tasks.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * test(complexity-report): Fix tag slugification in filename expectations - Update mocks to use slugifyTagForFilePath for cross-platform compatibility - Replace raw tag values with slugified versions in expected filenames - Fix test expecting 'feature/user-auth-v2' to expect 'feature-user-auth-v2' - Align test with actual filename generation logic that sanitizes special chars --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
974 lines
27 KiB
JavaScript
974 lines
27 KiB
JavaScript
/**
|
|
* contextGatherer.js
|
|
* Comprehensive context gathering utility for Task Master AI operations
|
|
* Supports task context, file context, project tree, and custom context
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import pkg from 'gpt-tokens';
|
|
import Fuse from 'fuse.js';
|
|
import {
|
|
readJSON,
|
|
findTaskById,
|
|
truncate,
|
|
flattenTasksWithSubtasks
|
|
} from '../utils.js';
|
|
|
|
const { encode } = pkg;
|
|
|
|
/**
|
|
* Context Gatherer class for collecting and formatting context from various sources
|
|
*/
|
|
export class ContextGatherer {
|
|
constructor(projectRoot, tag) {
|
|
this.projectRoot = projectRoot;
|
|
this.tasksPath = path.join(
|
|
projectRoot,
|
|
'.taskmaster',
|
|
'tasks',
|
|
'tasks.json'
|
|
);
|
|
this.tag = tag;
|
|
this.allTasks = this._loadAllTasks();
|
|
}
|
|
|
|
_loadAllTasks() {
|
|
try {
|
|
const data = readJSON(this.tasksPath, this.projectRoot, this.tag);
|
|
const tasks = data?.tasks || [];
|
|
return tasks;
|
|
} catch (error) {
|
|
console.warn(
|
|
`Warning: Could not load tasks for ContextGatherer: ${error.message}`
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count tokens in a text string using gpt-tokens
|
|
* @param {string} text - Text to count tokens for
|
|
* @returns {number} Token count
|
|
*/
|
|
countTokens(text) {
|
|
if (!text || typeof text !== 'string') {
|
|
return 0;
|
|
}
|
|
try {
|
|
return encode(text).length;
|
|
} catch (error) {
|
|
// Fallback to rough character-based estimation if tokenizer fails
|
|
// Rough estimate: ~4 characters per token for English text
|
|
return Math.ceil(text.length / 4);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main method to gather context from multiple sources
|
|
* @param {Object} options - Context gathering options
|
|
* @param {Array<string>} [options.tasks] - Task/subtask IDs to include
|
|
* @param {Array<string>} [options.files] - File paths to include
|
|
* @param {string} [options.customContext] - Additional custom context
|
|
* @param {boolean} [options.includeProjectTree] - Include project file tree
|
|
* @param {string} [options.format] - Output format: 'research', 'chat', 'system-prompt'
|
|
* @param {boolean} [options.includeTokenCounts] - Whether to include token breakdown
|
|
* @param {string} [options.semanticQuery] - A query string for semantic task searching.
|
|
* @param {number} [options.maxSemanticResults] - Max number of semantic results.
|
|
* @param {Array<number>} [options.dependencyTasks] - Array of task IDs to build dependency graphs from.
|
|
* @returns {Promise<Object>} Object with context string and analysis data
|
|
*/
|
|
async gather(options = {}) {
|
|
const {
|
|
tasks = [],
|
|
files = [],
|
|
customContext = '',
|
|
includeProjectTree = false,
|
|
format = 'research',
|
|
includeTokenCounts = false,
|
|
semanticQuery,
|
|
maxSemanticResults = 10,
|
|
dependencyTasks = []
|
|
} = options;
|
|
|
|
const contextSections = [];
|
|
const finalTaskIds = new Set(tasks.map(String));
|
|
let analysisData = null;
|
|
let tokenBreakdown = null;
|
|
|
|
// Initialize token breakdown if requested
|
|
if (includeTokenCounts) {
|
|
tokenBreakdown = {
|
|
total: 0,
|
|
customContext: null,
|
|
tasks: [],
|
|
files: [],
|
|
projectTree: null
|
|
};
|
|
}
|
|
|
|
// Semantic Search
|
|
if (semanticQuery && this.allTasks.length > 0) {
|
|
const semanticResults = this._performSemanticSearch(
|
|
semanticQuery,
|
|
maxSemanticResults
|
|
);
|
|
|
|
// Store the analysis data for UI display
|
|
analysisData = semanticResults.analysisData;
|
|
|
|
semanticResults.tasks.forEach((task) => {
|
|
finalTaskIds.add(String(task.id));
|
|
});
|
|
}
|
|
|
|
// Dependency Graph Analysis
|
|
if (dependencyTasks.length > 0) {
|
|
const dependencyResults = this._buildDependencyGraphs(dependencyTasks);
|
|
dependencyResults.allRelatedTaskIds.forEach((id) =>
|
|
finalTaskIds.add(String(id))
|
|
);
|
|
// We can format and add dependencyResults.graphVisualization later if needed
|
|
}
|
|
|
|
// Add custom context first
|
|
if (customContext && customContext.trim()) {
|
|
const formattedCustomContext = this._formatCustomContext(
|
|
customContext,
|
|
format
|
|
);
|
|
contextSections.push(formattedCustomContext);
|
|
|
|
// Calculate tokens for custom context if requested
|
|
if (includeTokenCounts) {
|
|
tokenBreakdown.customContext = {
|
|
tokens: this.countTokens(formattedCustomContext),
|
|
characters: formattedCustomContext.length
|
|
};
|
|
tokenBreakdown.total += tokenBreakdown.customContext.tokens;
|
|
}
|
|
}
|
|
|
|
// Gather context for the final list of tasks
|
|
if (finalTaskIds.size > 0) {
|
|
const taskContextResult = await this._gatherTaskContext(
|
|
Array.from(finalTaskIds),
|
|
format,
|
|
includeTokenCounts
|
|
);
|
|
if (taskContextResult.context) {
|
|
contextSections.push(taskContextResult.context);
|
|
|
|
// Add task breakdown if token counting is enabled
|
|
if (includeTokenCounts && taskContextResult.breakdown) {
|
|
tokenBreakdown.tasks = taskContextResult.breakdown;
|
|
const taskTokens = taskContextResult.breakdown.reduce(
|
|
(sum, task) => sum + task.tokens,
|
|
0
|
|
);
|
|
tokenBreakdown.total += taskTokens;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add file context
|
|
if (files.length > 0) {
|
|
const fileContextResult = await this._gatherFileContext(
|
|
files,
|
|
format,
|
|
includeTokenCounts
|
|
);
|
|
if (fileContextResult.context) {
|
|
contextSections.push(fileContextResult.context);
|
|
|
|
// Add file breakdown if token counting is enabled
|
|
if (includeTokenCounts && fileContextResult.breakdown) {
|
|
tokenBreakdown.files = fileContextResult.breakdown;
|
|
const fileTokens = fileContextResult.breakdown.reduce(
|
|
(sum, file) => sum + file.tokens,
|
|
0
|
|
);
|
|
tokenBreakdown.total += fileTokens;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add project tree context
|
|
if (includeProjectTree) {
|
|
const treeContextResult = await this._gatherProjectTreeContext(
|
|
format,
|
|
includeTokenCounts
|
|
);
|
|
if (treeContextResult.context) {
|
|
contextSections.push(treeContextResult.context);
|
|
|
|
// Add tree breakdown if token counting is enabled
|
|
if (includeTokenCounts && treeContextResult.breakdown) {
|
|
tokenBreakdown.projectTree = treeContextResult.breakdown;
|
|
tokenBreakdown.total += treeContextResult.breakdown.tokens;
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalContext = this._joinContextSections(contextSections, format);
|
|
|
|
const result = {
|
|
context: finalContext,
|
|
analysisData: analysisData,
|
|
contextSections: contextSections.length,
|
|
finalTaskIds: Array.from(finalTaskIds)
|
|
};
|
|
|
|
// Only include tokenBreakdown if it was requested
|
|
if (includeTokenCounts) {
|
|
result.tokenBreakdown = tokenBreakdown;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
_performSemanticSearch(query, maxResults) {
|
|
const searchableTasks = this.allTasks.map((task) => {
|
|
const dependencyTitles =
|
|
task.dependencies?.length > 0
|
|
? task.dependencies
|
|
.map((depId) => this.allTasks.find((t) => t.id === depId)?.title)
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
: '';
|
|
return { ...task, dependencyTitles };
|
|
});
|
|
|
|
// Use the exact same approach as add-task.js
|
|
const searchOptions = {
|
|
includeScore: true, // Return match scores
|
|
threshold: 0.4, // Lower threshold = stricter matching (range 0-1)
|
|
keys: [
|
|
{ name: 'title', weight: 1.5 }, // Title is most important
|
|
{ name: 'description', weight: 2 }, // Description is very important
|
|
{ name: 'details', weight: 3 }, // Details is most important
|
|
// Search dependencies to find tasks that depend on similar things
|
|
{ name: 'dependencyTitles', weight: 0.5 }
|
|
],
|
|
// Sort matches by score (lower is better)
|
|
shouldSort: true,
|
|
// Allow searching in nested properties
|
|
useExtendedSearch: true,
|
|
// Return up to 50 matches
|
|
limit: 50
|
|
};
|
|
|
|
// Create search index using Fuse.js
|
|
const fuse = new Fuse(searchableTasks, searchOptions);
|
|
|
|
// Extract significant words and phrases from the prompt (like add-task.js does)
|
|
const promptWords = query
|
|
.toLowerCase()
|
|
.replace(/[^\w\s-]/g, ' ') // Replace non-alphanumeric chars with spaces
|
|
.split(/\s+/)
|
|
.filter((word) => word.length > 3); // Words at least 4 chars
|
|
|
|
// Use the user's prompt for fuzzy search
|
|
const fuzzyResults = fuse.search(query);
|
|
|
|
// Also search for each significant word to catch different aspects
|
|
const wordResults = [];
|
|
for (const word of promptWords) {
|
|
if (word.length > 5) {
|
|
// Only use significant words
|
|
const results = fuse.search(word);
|
|
if (results.length > 0) {
|
|
wordResults.push(...results);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge and deduplicate results
|
|
const mergedResults = [...fuzzyResults];
|
|
|
|
// Add word results that aren't already in fuzzyResults
|
|
for (const wordResult of wordResults) {
|
|
if (!mergedResults.some((r) => r.item.id === wordResult.item.id)) {
|
|
mergedResults.push(wordResult);
|
|
}
|
|
}
|
|
|
|
// Group search results by relevance
|
|
const highRelevance = mergedResults
|
|
.filter((result) => result.score < 0.25)
|
|
.map((result) => result.item);
|
|
|
|
const mediumRelevance = mergedResults
|
|
.filter((result) => result.score >= 0.25 && result.score < 0.4)
|
|
.map((result) => result.item);
|
|
|
|
// Get recent tasks (newest first)
|
|
const recentTasks = [...this.allTasks]
|
|
.sort((a, b) => b.id - a.id)
|
|
.slice(0, 5);
|
|
|
|
// Combine high relevance, medium relevance, and recent tasks
|
|
// Prioritize high relevance first
|
|
const allRelevantTasks = [...highRelevance];
|
|
|
|
// Add medium relevance if not already included
|
|
for (const task of mediumRelevance) {
|
|
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
|
allRelevantTasks.push(task);
|
|
}
|
|
}
|
|
|
|
// Add recent tasks if not already included
|
|
for (const task of recentTasks) {
|
|
if (!allRelevantTasks.some((t) => t.id === task.id)) {
|
|
allRelevantTasks.push(task);
|
|
}
|
|
}
|
|
|
|
// Get top N results for context
|
|
const finalResults = allRelevantTasks.slice(0, maxResults);
|
|
return {
|
|
tasks: finalResults,
|
|
analysisData: {
|
|
highRelevance: highRelevance,
|
|
mediumRelevance: mediumRelevance,
|
|
recentTasks: recentTasks,
|
|
allRelevantTasks: allRelevantTasks
|
|
}
|
|
};
|
|
}
|
|
|
|
_buildDependencyContext(taskIds) {
|
|
const { allRelatedTaskIds, graphs, depthMap } =
|
|
this._buildDependencyGraphs(taskIds);
|
|
if (allRelatedTaskIds.size === 0) return '';
|
|
|
|
const dependentTasks = Array.from(allRelatedTaskIds)
|
|
.map((id) => this.allTasks.find((t) => t.id === id))
|
|
.filter(Boolean)
|
|
.sort((a, b) => (depthMap.get(a.id) || 0) - (depthMap.get(b.id) || 0));
|
|
|
|
const uniqueDetailedTasks = dependentTasks.slice(0, 8);
|
|
|
|
let context = `\nThis task relates to a dependency structure with ${dependentTasks.length} related tasks in the chain.`;
|
|
|
|
const directDeps = this.allTasks.filter((t) => taskIds.includes(t.id));
|
|
if (directDeps.length > 0) {
|
|
context += `\n\nDirect dependencies:\n${directDeps
|
|
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
|
|
.join('\n')}`;
|
|
}
|
|
|
|
const indirectDeps = dependentTasks.filter((t) => !taskIds.includes(t.id));
|
|
if (indirectDeps.length > 0) {
|
|
context += `\n\nIndirect dependencies (dependencies of dependencies):\n${indirectDeps
|
|
.slice(0, 5)
|
|
.map((t) => `- Task ${t.id}: ${t.title} - ${t.description}`)
|
|
.join('\n')}`;
|
|
if (indirectDeps.length > 5)
|
|
context += `\n- ... and ${
|
|
indirectDeps.length - 5
|
|
} more indirect dependencies`;
|
|
}
|
|
|
|
context += `\n\nDetailed information about dependencies:`;
|
|
for (const depTask of uniqueDetailedTasks) {
|
|
const isDirect = taskIds.includes(depTask.id)
|
|
? ' [DIRECT DEPENDENCY]'
|
|
: '';
|
|
context += `\n\n------ Task ${depTask.id}${isDirect}: ${depTask.title} ------\n`;
|
|
context += `Description: ${depTask.description}\n`;
|
|
if (depTask.dependencies?.length) {
|
|
context += `Dependencies: ${depTask.dependencies.join(', ')}\n`;
|
|
}
|
|
if (depTask.details) {
|
|
context += `Implementation Details: ${truncate(
|
|
depTask.details,
|
|
400
|
|
)}\n`;
|
|
}
|
|
}
|
|
|
|
if (graphs.length > 0) {
|
|
context += '\n\nDependency Chain Visualization:';
|
|
context += graphs
|
|
.map((graph) => this._formatDependencyChain(graph))
|
|
.join('');
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
_buildDependencyGraphs(taskIds) {
|
|
const visited = new Set();
|
|
const depthMap = new Map();
|
|
const graphs = [];
|
|
|
|
for (const id of taskIds) {
|
|
const graph = this._buildDependencyGraph(id, visited, depthMap);
|
|
if (graph) graphs.push(graph);
|
|
}
|
|
|
|
return { allRelatedTaskIds: visited, graphs, depthMap };
|
|
}
|
|
|
|
_buildDependencyGraph(taskId, visited, depthMap, depth = 0) {
|
|
if (visited.has(taskId) || depth > 5) return null; // Limit recursion depth
|
|
const task = this.allTasks.find((t) => t.id === taskId);
|
|
if (!task) return null;
|
|
|
|
visited.add(taskId);
|
|
if (!depthMap.has(taskId) || depth < depthMap.get(taskId)) {
|
|
depthMap.set(taskId, depth);
|
|
}
|
|
|
|
const dependencies =
|
|
task.dependencies
|
|
?.map((depId) =>
|
|
this._buildDependencyGraph(depId, visited, depthMap, depth + 1)
|
|
)
|
|
.filter(Boolean) || [];
|
|
|
|
return { ...task, dependencies };
|
|
}
|
|
|
|
_formatDependencyChain(node, prefix = '', isLast = true, depth = 0) {
|
|
if (depth > 3) return '';
|
|
const connector = isLast ? '└── ' : '├── ';
|
|
let result = `${prefix}${connector}Task ${node.id}: ${node.title}`;
|
|
if (node.dependencies?.length) {
|
|
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
result += node.dependencies
|
|
.map((dep, index) =>
|
|
this._formatDependencyChain(
|
|
dep,
|
|
childPrefix,
|
|
index === node.dependencies.length - 1,
|
|
depth + 1
|
|
)
|
|
)
|
|
.join('');
|
|
}
|
|
return '\n' + result;
|
|
}
|
|
|
|
/**
|
|
* Parse task ID strings into structured format
|
|
* Supports formats: "15", "15.2", "16,17.1"
|
|
* @param {Array<string>} taskIds - Array of task ID strings
|
|
* @returns {Array<Object>} Parsed task identifiers
|
|
*/
|
|
_parseTaskIds(taskIds) {
|
|
const parsed = [];
|
|
|
|
for (const idStr of taskIds) {
|
|
if (idStr.includes('.')) {
|
|
// Subtask format: "15.2"
|
|
const [parentId, subtaskId] = idStr.split('.');
|
|
parsed.push({
|
|
type: 'subtask',
|
|
parentId: parseInt(parentId, 10),
|
|
subtaskId: parseInt(subtaskId, 10),
|
|
fullId: idStr
|
|
});
|
|
} else {
|
|
// Task format: "15"
|
|
parsed.push({
|
|
type: 'task',
|
|
taskId: parseInt(idStr, 10),
|
|
fullId: idStr
|
|
});
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Gather context from tasks and subtasks
|
|
* @param {Array<string>} taskIds - Task/subtask IDs
|
|
* @param {string} format - Output format
|
|
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
|
* @returns {Promise<Object>} Task context result with breakdown
|
|
*/
|
|
async _gatherTaskContext(taskIds, format, includeTokenCounts = false) {
|
|
try {
|
|
if (!this.allTasks || this.allTasks.length === 0) {
|
|
return { context: null, breakdown: [] };
|
|
}
|
|
|
|
const parsedIds = this._parseTaskIds(taskIds);
|
|
const contextItems = [];
|
|
const breakdown = [];
|
|
|
|
for (const parsed of parsedIds) {
|
|
let formattedItem = null;
|
|
let itemInfo = null;
|
|
|
|
if (parsed.type === 'task') {
|
|
const result = findTaskById(this.allTasks, parsed.taskId);
|
|
if (result.task) {
|
|
formattedItem = this._formatTaskForContext(result.task, format);
|
|
itemInfo = {
|
|
id: parsed.fullId,
|
|
type: 'task',
|
|
title: result.task.title,
|
|
tokens: includeTokenCounts ? this.countTokens(formattedItem) : 0,
|
|
characters: formattedItem.length
|
|
};
|
|
}
|
|
} else if (parsed.type === 'subtask') {
|
|
const parentResult = findTaskById(this.allTasks, parsed.parentId);
|
|
if (parentResult.task && parentResult.task.subtasks) {
|
|
const subtask = parentResult.task.subtasks.find(
|
|
(st) => st.id === parsed.subtaskId
|
|
);
|
|
if (subtask) {
|
|
formattedItem = this._formatSubtaskForContext(
|
|
subtask,
|
|
parentResult.task,
|
|
format
|
|
);
|
|
itemInfo = {
|
|
id: parsed.fullId,
|
|
type: 'subtask',
|
|
title: subtask.title,
|
|
parentTitle: parentResult.task.title,
|
|
tokens: includeTokenCounts
|
|
? this.countTokens(formattedItem)
|
|
: 0,
|
|
characters: formattedItem.length
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (formattedItem && itemInfo) {
|
|
contextItems.push(formattedItem);
|
|
if (includeTokenCounts) {
|
|
breakdown.push(itemInfo);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (contextItems.length === 0) {
|
|
return { context: null, breakdown: [] };
|
|
}
|
|
|
|
const finalContext = this._formatTaskContextSection(contextItems, format);
|
|
return {
|
|
context: finalContext,
|
|
breakdown: includeTokenCounts ? breakdown : []
|
|
};
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not gather task context: ${error.message}`);
|
|
return { context: null, breakdown: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a task for context inclusion
|
|
* @param {Object} task - Task object
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted task context
|
|
*/
|
|
_formatTaskForContext(task, format) {
|
|
const sections = [];
|
|
|
|
sections.push(`**Task ${task.id}: ${task.title}**`);
|
|
sections.push(`Description: ${task.description}`);
|
|
sections.push(`Status: ${task.status || 'pending'}`);
|
|
sections.push(`Priority: ${task.priority || 'medium'}`);
|
|
|
|
if (task.dependencies && task.dependencies.length > 0) {
|
|
sections.push(`Dependencies: ${task.dependencies.join(', ')}`);
|
|
}
|
|
|
|
if (task.details) {
|
|
const details = truncate(task.details, 500);
|
|
sections.push(`Implementation Details: ${details}`);
|
|
}
|
|
|
|
if (task.testStrategy) {
|
|
const testStrategy = truncate(task.testStrategy, 300);
|
|
sections.push(`Test Strategy: ${testStrategy}`);
|
|
}
|
|
|
|
if (task.subtasks && task.subtasks.length > 0) {
|
|
sections.push(`Subtasks: ${task.subtasks.length} subtasks defined`);
|
|
}
|
|
|
|
return sections.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Format a subtask for context inclusion
|
|
* @param {Object} subtask - Subtask object
|
|
* @param {Object} parentTask - Parent task object
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted subtask context
|
|
*/
|
|
_formatSubtaskForContext(subtask, parentTask, format) {
|
|
const sections = [];
|
|
|
|
sections.push(
|
|
`**Subtask ${parentTask.id}.${subtask.id}: ${subtask.title}**`
|
|
);
|
|
sections.push(`Parent Task: ${parentTask.title}`);
|
|
sections.push(`Description: ${subtask.description}`);
|
|
sections.push(`Status: ${subtask.status || 'pending'}`);
|
|
|
|
if (subtask.dependencies && subtask.dependencies.length > 0) {
|
|
sections.push(`Dependencies: ${subtask.dependencies.join(', ')}`);
|
|
}
|
|
|
|
if (subtask.details) {
|
|
const details = truncate(subtask.details, 500);
|
|
sections.push(`Implementation Details: ${details}`);
|
|
}
|
|
|
|
return sections.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Gather context from files
|
|
* @param {Array<string>} filePaths - File paths to read
|
|
* @param {string} format - Output format
|
|
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
|
* @returns {Promise<Object>} File context result with breakdown
|
|
*/
|
|
async _gatherFileContext(filePaths, format, includeTokenCounts = false) {
|
|
const fileContents = [];
|
|
const breakdown = [];
|
|
|
|
for (const filePath of filePaths) {
|
|
try {
|
|
const fullPath = path.isAbsolute(filePath)
|
|
? filePath
|
|
: path.join(this.projectRoot, filePath);
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
continue;
|
|
}
|
|
|
|
const stats = fs.statSync(fullPath);
|
|
if (!stats.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
// Check file size (limit to 50KB for context)
|
|
if (stats.size > 50 * 1024) {
|
|
continue;
|
|
}
|
|
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
const relativePath = path.relative(this.projectRoot, fullPath);
|
|
|
|
const fileData = {
|
|
path: relativePath,
|
|
size: stats.size,
|
|
content: content,
|
|
lastModified: stats.mtime
|
|
};
|
|
|
|
fileContents.push(fileData);
|
|
|
|
// Calculate tokens for this individual file if requested
|
|
if (includeTokenCounts) {
|
|
const formattedFile = this._formatSingleFileForContext(
|
|
fileData,
|
|
format
|
|
);
|
|
breakdown.push({
|
|
path: relativePath,
|
|
sizeKB: Math.round(stats.size / 1024),
|
|
tokens: this.countTokens(formattedFile),
|
|
characters: formattedFile.length
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`Warning: Could not read file ${filePath}: ${error.message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (fileContents.length === 0) {
|
|
return { context: null, breakdown: [] };
|
|
}
|
|
|
|
const finalContext = this._formatFileContextSection(fileContents, format);
|
|
return {
|
|
context: finalContext,
|
|
breakdown: includeTokenCounts ? breakdown : []
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate project file tree context
|
|
* @param {string} format - Output format
|
|
* @param {boolean} includeTokenCounts - Whether to include token breakdown
|
|
* @returns {Promise<Object>} Project tree context result with breakdown
|
|
*/
|
|
async _gatherProjectTreeContext(format, includeTokenCounts = false) {
|
|
try {
|
|
const tree = this._generateFileTree(this.projectRoot, 5); // Max depth 5
|
|
const finalContext = this._formatProjectTreeSection(tree, format);
|
|
|
|
const breakdown = includeTokenCounts
|
|
? {
|
|
tokens: this.countTokens(finalContext),
|
|
characters: finalContext.length,
|
|
fileCount: tree.fileCount || 0,
|
|
dirCount: tree.dirCount || 0
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
context: finalContext,
|
|
breakdown: breakdown
|
|
};
|
|
} catch (error) {
|
|
console.warn(
|
|
`Warning: Could not generate project tree: ${error.message}`
|
|
);
|
|
return { context: null, breakdown: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a single file for context (used for token counting)
|
|
* @param {Object} fileData - File data object
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted file context
|
|
*/
|
|
_formatSingleFileForContext(fileData, format) {
|
|
const header = `**File: ${fileData.path}** (${Math.round(fileData.size / 1024)}KB)`;
|
|
const content = `\`\`\`\n${fileData.content}\n\`\`\``;
|
|
return `${header}\n\n${content}`;
|
|
}
|
|
|
|
/**
|
|
* Generate file tree structure
|
|
* @param {string} dirPath - Directory path
|
|
* @param {number} maxDepth - Maximum depth to traverse
|
|
* @param {number} currentDepth - Current depth
|
|
* @returns {Object} File tree structure
|
|
*/
|
|
_generateFileTree(dirPath, maxDepth, currentDepth = 0) {
|
|
const ignoreDirs = [
|
|
'.git',
|
|
'node_modules',
|
|
'.env',
|
|
'coverage',
|
|
'dist',
|
|
'build'
|
|
];
|
|
const ignoreFiles = ['.DS_Store', '.env', '.env.local', '.env.production'];
|
|
|
|
if (currentDepth >= maxDepth) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const items = fs.readdirSync(dirPath);
|
|
const tree = {
|
|
name: path.basename(dirPath),
|
|
type: 'directory',
|
|
children: [],
|
|
fileCount: 0,
|
|
dirCount: 0
|
|
};
|
|
|
|
for (const item of items) {
|
|
if (ignoreDirs.includes(item) || ignoreFiles.includes(item)) {
|
|
continue;
|
|
}
|
|
|
|
const itemPath = path.join(dirPath, item);
|
|
const stats = fs.statSync(itemPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
tree.dirCount++;
|
|
if (currentDepth < maxDepth - 1) {
|
|
const subtree = this._generateFileTree(
|
|
itemPath,
|
|
maxDepth,
|
|
currentDepth + 1
|
|
);
|
|
if (subtree) {
|
|
tree.children.push(subtree);
|
|
}
|
|
}
|
|
} else {
|
|
tree.fileCount++;
|
|
tree.children.push({
|
|
name: item,
|
|
type: 'file',
|
|
size: stats.size
|
|
});
|
|
}
|
|
}
|
|
|
|
return tree;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format custom context section
|
|
* @param {string} customContext - Custom context string
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted custom context
|
|
*/
|
|
_formatCustomContext(customContext, format) {
|
|
switch (format) {
|
|
case 'research':
|
|
return `## Additional Context\n\n${customContext}`;
|
|
case 'chat':
|
|
return `**Additional Context:**\n${customContext}`;
|
|
case 'system-prompt':
|
|
return `Additional context: ${customContext}`;
|
|
default:
|
|
return customContext;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format task context section
|
|
* @param {Array<string>} taskItems - Formatted task items
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted task context section
|
|
*/
|
|
_formatTaskContextSection(taskItems, format) {
|
|
switch (format) {
|
|
case 'research':
|
|
return `## Task Context\n\n${taskItems.join('\n\n---\n\n')}`;
|
|
case 'chat':
|
|
return `**Task Context:**\n\n${taskItems.join('\n\n')}`;
|
|
case 'system-prompt':
|
|
return `Task context: ${taskItems.join(' | ')}`;
|
|
default:
|
|
return taskItems.join('\n\n');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format file context section
|
|
* @param {Array<Object>} fileContents - File content objects
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted file context section
|
|
*/
|
|
_formatFileContextSection(fileContents, format) {
|
|
const fileItems = fileContents.map((file) => {
|
|
const header = `**File: ${file.path}** (${Math.round(file.size / 1024)}KB)`;
|
|
const content = `\`\`\`\n${file.content}\n\`\`\``;
|
|
return `${header}\n\n${content}`;
|
|
});
|
|
|
|
switch (format) {
|
|
case 'research':
|
|
return `## File Context\n\n${fileItems.join('\n\n---\n\n')}`;
|
|
case 'chat':
|
|
return `**File Context:**\n\n${fileItems.join('\n\n')}`;
|
|
case 'system-prompt':
|
|
return `File context: ${fileContents.map((f) => `${f.path} (${f.content.substring(0, 200)}...)`).join(' | ')}`;
|
|
default:
|
|
return fileItems.join('\n\n');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format project tree section
|
|
* @param {Object} tree - File tree structure
|
|
* @param {string} format - Output format
|
|
* @returns {string} Formatted project tree section
|
|
*/
|
|
_formatProjectTreeSection(tree, format) {
|
|
const treeString = this._renderFileTree(tree);
|
|
|
|
switch (format) {
|
|
case 'research':
|
|
return `## Project Structure\n\n\`\`\`\n${treeString}\n\`\`\``;
|
|
case 'chat':
|
|
return `**Project Structure:**\n\`\`\`\n${treeString}\n\`\`\``;
|
|
case 'system-prompt':
|
|
return `Project structure: ${treeString.replace(/\n/g, ' | ')}`;
|
|
default:
|
|
return treeString;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render file tree as string
|
|
* @param {Object} tree - File tree structure
|
|
* @param {string} prefix - Current prefix for indentation
|
|
* @returns {string} Rendered tree string
|
|
*/
|
|
_renderFileTree(tree, prefix = '') {
|
|
let result = `${prefix}${tree.name}/`;
|
|
|
|
if (tree.fileCount > 0 || tree.dirCount > 0) {
|
|
result += ` (${tree.fileCount} files, ${tree.dirCount} dirs)`;
|
|
}
|
|
|
|
result += '\n';
|
|
|
|
if (tree.children) {
|
|
tree.children.forEach((child, index) => {
|
|
const isLast = index === tree.children.length - 1;
|
|
const childPrefix = prefix + (isLast ? '└── ' : '├── ');
|
|
const nextPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
|
|
if (child.type === 'directory') {
|
|
result += this._renderFileTree(child, childPrefix);
|
|
} else {
|
|
result += `${childPrefix}${child.name}\n`;
|
|
}
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Join context sections based on format
|
|
* @param {Array<string>} sections - Context sections
|
|
* @param {string} format - Output format
|
|
* @returns {string} Joined context string
|
|
*/
|
|
_joinContextSections(sections, format) {
|
|
if (sections.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
switch (format) {
|
|
case 'research':
|
|
return sections.join('\n\n---\n\n');
|
|
case 'chat':
|
|
return sections.join('\n\n');
|
|
case 'system-prompt':
|
|
return sections.join(' ');
|
|
default:
|
|
return sections.join('\n\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Factory function to create a context gatherer instance
|
|
* @param {string} projectRoot - Project root directory
|
|
* @param {string} tag - Tag for the task
|
|
* @returns {ContextGatherer} Context gatherer instance
|
|
* @throws {Error} If tag is not provided
|
|
*/
|
|
export function createContextGatherer(projectRoot, tag) {
|
|
if (!tag) {
|
|
throw new Error('Tag is required');
|
|
}
|
|
return new ContextGatherer(projectRoot, tag);
|
|
}
|
|
|
|
export default ContextGatherer;
|