chore: apply requested changes

This commit is contained in:
Ralph Khreish
2025-10-02 15:09:18 +02:00
parent 1d197fe9c2
commit f68330efb3
12 changed files with 148 additions and 61 deletions

View File

@@ -163,9 +163,13 @@ export async function performAutoUpdate(
process.env.CI || process.env.CI ||
process.env.NODE_ENV === 'test' process.env.NODE_ENV === 'test'
) { ) {
console.log( const reason =
chalk.dim('Skipping auto-update (TASKMASTER_SKIP_AUTO_UPDATE/CI).') process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1'
); ? 'TASKMASTER_SKIP_AUTO_UPDATE=1'
: process.env.CI
? 'CI environment'
: 'NODE_ENV=test';
console.log(chalk.dim(`Skipping auto-update (${reason})`));
return false; return false;
} }
const spinner = ora({ const spinner = ora({

View File

@@ -290,7 +290,7 @@ async function expandTask(
); );
// --- End Complexity Report / Prompt Logic --- // --- End Complexity Report / Prompt Logic ---
// --- AI Subtask Generation using generateTextService --- // --- AI Subtask Generation using generateObjectService ---
let generatedSubtasks = []; let generatedSubtasks = [];
let loadingIndicator = null; let loadingIndicator = null;
if (outputFormat === 'text') { if (outputFormat === 'text') {
@@ -318,8 +318,12 @@ async function expandTask(
outputType: outputFormat outputType: outputFormat
}); });
// With generateObject, we get structured data directly // With generateObject, we expect structured data verify it before use
generatedSubtasks = aiServiceResponse.mainResult.subtasks; const mainResult = aiServiceResponse?.mainResult;
if (!mainResult || !Array.isArray(mainResult.subtasks)) {
throw new Error('AI response did not include a valid subtasks array.');
}
generatedSubtasks = mainResult.subtasks;
logger.info(`Received ${generatedSubtasks.length} subtasks from AI.`); logger.info(`Received ${generatedSubtasks.length} subtasks from AI.`);
} catch (error) { } catch (error) {
if (loadingIndicator) stopLoadingIndicator(loadingIndicator); if (loadingIndicator) stopLoadingIndicator(loadingIndicator);

View File

@@ -21,6 +21,13 @@ export class BaseAIProvider {
// Each provider must set their name // Each provider must set their name
this.name = this.constructor.name; this.name = this.constructor.name;
/**
* Whether this provider needs explicit schema in JSON mode
* Can be overridden by subclasses
* @type {boolean}
*/
this.needsExplicitJsonSchema = false;
} }
/** /**
@@ -273,15 +280,11 @@ export class BaseAIProvider {
const client = await this.getClient(params); const client = await this.getClient(params);
// For providers that don't support tool mode (like claude-code),
// we need to ensure the schema is properly communicated in the prompt
const needsExplicitSchema = this.name === 'Claude Code';
const result = await generateObject({ const result = await generateObject({
model: client(params.modelId), model: client(params.modelId),
messages: params.messages, messages: params.messages,
schema: params.schema, schema: params.schema,
mode: needsExplicitSchema ? 'json' : 'auto', mode: this.needsExplicitJsonSchema ? 'json' : 'auto',
schemaName: params.objectName, schemaName: params.objectName,
schemaDescription: `Generate a valid JSON object for ${params.objectName}`, schemaDescription: `Generate a valid JSON object for ${params.objectName}`,
maxTokens: params.maxTokens, maxTokens: params.maxTokens,
@@ -305,7 +308,7 @@ export class BaseAIProvider {
// Check if this is a JSON parsing error that we can potentially fix // Check if this is a JSON parsing error that we can potentially fix
if ( if (
NoObjectGeneratedError.isInstance(error) && NoObjectGeneratedError.isInstance(error) &&
JSONParseError.isInstance(error.cause) && error.cause instanceof JSONParseError &&
error.cause.text error.cause.text
) { ) {
log( log(

View File

@@ -32,6 +32,8 @@ export class ClaudeCodeProvider extends BaseAIProvider {
super(); super();
this.name = 'Claude Code'; this.name = 'Claude Code';
this.supportedModels = ['sonnet', 'opus']; this.supportedModels = ['sonnet', 'opus'];
// Claude Code requires explicit JSON schema mode
this.needsExplicitJsonSchema = true;
} }
/** /**

View File

@@ -15,6 +15,8 @@ export class GeminiCliProvider extends BaseAIProvider {
constructor() { constructor() {
super(); super();
this.name = 'Gemini CLI'; this.name = 'Gemini CLI';
// Gemini CLI requires explicit JSON schema mode
this.needsExplicitJsonSchema = true;
} }
/** /**
@@ -587,7 +589,7 @@ Generate ${subtaskCount} subtasks based on the original task context. Return ONL
system: systemPrompt, system: systemPrompt,
messages: messages, messages: messages,
schema: params.schema, schema: params.schema,
mode: 'json', // Use json mode instead of auto for Gemini mode: this.needsExplicitJsonSchema ? 'json' : 'auto',
maxOutputTokens: params.maxTokens, maxOutputTokens: params.maxTokens,
temperature: params.temperature temperature: params.temperature
}); });

View File

@@ -11,6 +11,8 @@ export class GrokCliProvider extends BaseAIProvider {
constructor() { constructor() {
super(); super();
this.name = 'Grok CLI'; this.name = 'Grok CLI';
// Grok CLI requires explicit JSON schema mode
this.needsExplicitJsonSchema = true;
} }
/** /**

View File

@@ -44,7 +44,7 @@
}, },
"prompts": { "prompts": {
"default": { "default": {
"system": "You are an expert software architect and project manager analyzing task complexity. Your analysis should consider implementation effort, technical challenges, dependencies, and testing requirements.\n\nIMPORTANT: For each task, provide an analysis object with ALL of the following fields:\n- taskId: The ID of the task being analyzed (positive integer)\n- taskTitle: The title of the task\n- complexityScore: A score from 1-10 indicating complexity\n- recommendedSubtasks: Number of subtasks recommended (positive integer)\n- expansionPrompt: A prompt to guide subtask generation\n- reasoning: Your reasoning for the complexity score", "system": "You are an expert software architect and project manager analyzing task complexity. Your analysis should consider implementation effort, technical challenges, dependencies, and testing requirements.\n\nIMPORTANT: For each task, provide an analysis object with ALL of the following fields:\n- taskId: The ID of the task being analyzed (positive integer)\n- taskTitle: The title of the task\n- complexityScore: A score from 1-10 indicating complexity\n- recommendedSubtasks: Number of subtasks recommended (non-negative integer; 0 if no expansion needed)\n- expansionPrompt: A prompt to guide subtask generation\n- reasoning: Your reasoning for the complexity score",
"user": "{{#if hasCodebaseAnalysis}}## IMPORTANT: Codebase Analysis Required\n\nYou have access to powerful codebase analysis tools. Before analyzing task complexity:\n\n1. Use the Glob tool to explore the project structure and understand the codebase size\n2. Use the Grep tool to search for existing implementations related to each task\n3. Use the Read tool to examine key files that would be affected by these tasks\n4. Understand the current implementation state, patterns used, and technical debt\n\nBased on your codebase analysis:\n- Assess complexity based on ACTUAL code that needs to be modified/created\n- Consider existing abstractions and patterns that could simplify implementation\n- Identify tasks that require refactoring vs. greenfield development\n- Factor in dependencies between existing code and new features\n- Provide more accurate subtask recommendations based on real code structure\n\nProject Root: {{projectRoot}}\n\n{{/if}}Analyze the following tasks to determine their complexity (1-10 scale) and recommend the number of subtasks for expansion. Provide a brief reasoning and an initial expansion prompt for each.{{#if useResearch}} Consider current best practices, common implementation patterns, and industry standards in your analysis.{{/if}}\n\nTasks:\n{{{json tasks}}}\n{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}\n{{/if}}\n" "user": "{{#if hasCodebaseAnalysis}}## IMPORTANT: Codebase Analysis Required\n\nYou have access to powerful codebase analysis tools. Before analyzing task complexity:\n\n1. Use the Glob tool to explore the project structure and understand the codebase size\n2. Use the Grep tool to search for existing implementations related to each task\n3. Use the Read tool to examine key files that would be affected by these tasks\n4. Understand the current implementation state, patterns used, and technical debt\n\nBased on your codebase analysis:\n- Assess complexity based on ACTUAL code that needs to be modified/created\n- Consider existing abstractions and patterns that could simplify implementation\n- Identify tasks that require refactoring vs. greenfield development\n- Factor in dependencies between existing code and new features\n- Provide more accurate subtask recommendations based on real code structure\n\nProject Root: {{projectRoot}}\n\n{{/if}}Analyze the following tasks to determine their complexity (1-10 scale) and recommend the number of subtasks for expansion. Provide a brief reasoning and an initial expansion prompt for each.{{#if useResearch}} Consider current best practices, common implementation patterns, and industry standards in your analysis.{{/if}}\n\nTasks:\n{{{json tasks}}}\n{{#if gatheredContext}}\n\n# Project Context\n\n{{gatheredContext}}\n{{/if}}\n"
} }
} }

View File

@@ -4,7 +4,7 @@ export const ComplexityAnalysisItemSchema = z.object({
taskId: z.number().int().positive(), taskId: z.number().int().positive(),
taskTitle: z.string(), taskTitle: z.string(),
complexityScore: z.number().min(1).max(10), complexityScore: z.number().min(1).max(10),
recommendedSubtasks: z.number().int().positive(), recommendedSubtasks: z.number().int().nonnegative(),
expansionPrompt: z.string(), expansionPrompt: z.string(),
reasoning: z.string() reasoning: z.string()
}); });

View File

@@ -379,9 +379,24 @@ describe('Complex Cross-Tag Scenarios', () => {
// Verify the move was successful // Verify the move was successful
const tasksAfter = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); const tasksAfter = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
expect(
tasksAfter['in-progress'].tasks.find((t) => t.id === 25) // Verify all tasks in the dependency chain were moved
).toBeDefined(); for (let i = 1; i <= 25; i++) {
expect(tasksAfter.master.tasks.find((t) => t.id === i)).toBeUndefined();
expect(
tasksAfter['in-progress'].tasks.find((t) => t.id === i)
).toBeDefined();
}
// Verify in-progress still has its original tasks (26-50)
for (let i = 26; i <= 50; i++) {
expect(
tasksAfter['in-progress'].tasks.find((t) => t.id === i)
).toBeDefined();
}
// Final count check
expect(tasksAfter['in-progress'].tasks).toHaveLength(50); // 25 moved + 25 original
}); });
}); });

View File

@@ -1,4 +1,6 @@
import { PromptManager } from '../../../scripts/modules/prompt-manager.js'; import { PromptManager } from '../../../scripts/modules/prompt-manager.js';
import { ExpandTaskResponseSchema } from '../../../src/schemas/expand-task.js';
import { SubtaskSchema } from '../../../src/schemas/base-schemas.js';
describe('expand-task prompt template', () => { describe('expand-task prompt template', () => {
let promptManager; let promptManager;
@@ -77,29 +79,21 @@ describe('expand-task prompt template', () => {
expect(userPrompt).toContain(params.complexityReasoningContext); expect(userPrompt).toContain(params.complexityReasoningContext);
}); });
test('all variants request structured subtasks with required fields', () => { test('ExpandTaskResponseSchema defines required subtask fields', () => {
const variants = ['default', 'research', 'complexity-report']; // Test the schema definition directly instead of weak substring matching
const schema = ExpandTaskResponseSchema;
const subtasksSchema = schema.shape.subtasks;
const subtaskSchema = subtasksSchema.element;
variants.forEach((variant) => { // Verify the schema has the required fields
const params = expect(subtaskSchema).toBe(SubtaskSchema);
variant === 'complexity-report' expect(SubtaskSchema.shape).toHaveProperty('id');
? { ...baseParams, expansionPrompt: 'test' } expect(SubtaskSchema.shape).toHaveProperty('title');
: baseParams; expect(SubtaskSchema.shape).toHaveProperty('description');
expect(SubtaskSchema.shape).toHaveProperty('dependencies');
const { systemPrompt, userPrompt } = promptManager.loadPrompt( expect(SubtaskSchema.shape).toHaveProperty('details');
'expand-task', expect(SubtaskSchema.shape).toHaveProperty('status');
params, expect(SubtaskSchema.shape).toHaveProperty('testStrategy');
variant
);
const combined = systemPrompt + userPrompt;
// Verify prompts describe the structured output format
expect(combined.toLowerCase()).toContain('subtasks');
expect(combined).toContain('id');
expect(combined).toContain('title');
expect(combined).toContain('description');
expect(combined).toContain('dependencies');
});
}); });
test('complexity-report variant fails without task context regression test', () => { test('complexity-report variant fails without task context regression test', () => {

View File

@@ -15,9 +15,10 @@ describe('Prompt Migration Validation', () => {
'code block markers' 'code block markers'
]; ];
// Special cases where phrases are okay in different contexts // Map banned phrases to contexts where they're allowed
const allowedContexts = { const allowedContexts = {
'markdown formatting': ['Use markdown formatting for better readability'] 'respond only with': ['Use markdown formatting for better readability'],
'return only the': ['Use markdown formatting for better readability']
}; };
test('prompts should not contain JSON formatting instructions', () => { test('prompts should not contain JSON formatting instructions', () => {
@@ -29,7 +30,6 @@ describe('Prompt Migration Validation', () => {
promptFiles.forEach((file) => { promptFiles.forEach((file) => {
const content = fs.readFileSync(path.join(promptsDir, file), 'utf8'); const content = fs.readFileSync(path.join(promptsDir, file), 'utf8');
const promptData = JSON.parse(content);
bannedPhrases.forEach((phrase) => { bannedPhrases.forEach((phrase) => {
const lowerContent = content.toLowerCase(); const lowerContent = content.toLowerCase();

View File

@@ -660,25 +660,38 @@ describe('expandTask', () => {
// Act // Act
await expandTask(tasksPath, taskId, 3, false, '', context, false); await expandTask(tasksPath, taskId, 3, false, '', context, false);
// Assert - Should append to existing subtasks with proper ID increments // Assert - Verify generateObjectService was called correctly
expect(writeJSON).toHaveBeenCalledWith( expect(generateObjectService).toHaveBeenCalledWith(
tasksPath,
expect.objectContaining({ expect.objectContaining({
tasks: expect.arrayContaining([ role: 'main',
expect.objectContaining({ commandName: 'expand-task',
id: 4, objectName: 'subtasks'
subtasks: expect.arrayContaining([ })
// Should contain both existing and new subtasks );
expect.any(Object),
expect.any(Object), // Assert - Verify data was written with appended subtasks
expect.any(Object), expect(writeJSON).toHaveBeenCalled();
expect.any(Object) // 1 existing + 3 new = 4 total const writeCall = writeJSON.mock.calls[0];
]) const savedData = writeCall[1]; // Second argument is the data
}) const task4 = savedData.tasks.find((t) => t.id === 4);
])
}), // Should have 4 subtasks total (1 existing + 3 new)
'/mock/project/root', expect(task4.subtasks).toHaveLength(4);
undefined
// Verify existing subtask is preserved at index 0
expect(task4.subtasks[0]).toEqual(
expect.objectContaining({
id: 1,
title: 'Existing subtask'
})
);
// Verify new subtasks were appended (they start with id=1 from AI)
expect(task4.subtasks[1]).toEqual(
expect.objectContaining({
id: 1,
title: 'Set up project structure'
})
); );
}); });
}); });
@@ -843,6 +856,54 @@ describe('expandTask', () => {
expect(writeJSON).not.toHaveBeenCalled(); expect(writeJSON).not.toHaveBeenCalled();
}); });
test('should handle missing mainResult from AI response', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const taskId = '2';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
};
// Mock AI service returning response without mainResult
generateObjectService.mockResolvedValueOnce({
telemetryData: { inputTokens: 100, outputTokens: 50 }
// Missing mainResult
});
// Act & Assert
await expect(
expandTask(tasksPath, taskId, 3, false, '', context, false)
).rejects.toThrow('AI response did not include a valid subtasks array.');
expect(writeJSON).not.toHaveBeenCalled();
});
test('should handle invalid subtasks array from AI response', async () => {
// Arrange
const tasksPath = 'tasks/tasks.json';
const taskId = '2';
const context = {
mcpLog: createMcpLogMock(),
projectRoot: '/mock/project/root'
};
// Mock AI service returning response with invalid subtasks
generateObjectService.mockResolvedValueOnce({
mainResult: {
subtasks: 'not-an-array' // Invalid: should be an array
},
telemetryData: { inputTokens: 100, outputTokens: 50 }
});
// Act & Assert
await expect(
expandTask(tasksPath, taskId, 3, false, '', context, false)
).rejects.toThrow('AI response did not include a valid subtasks array.');
expect(writeJSON).not.toHaveBeenCalled();
});
test('should handle file read errors', async () => { test('should handle file read errors', async () => {
// Arrange // Arrange
const tasksPath = 'tasks/tasks.json'; const tasksPath = 'tasks/tasks.json';