feat: add expand-task remote (#1384)

This commit is contained in:
Ralph Khreish
2025-11-07 18:06:23 +01:00
parent ac4328ae86
commit 3d7e77c178
21 changed files with 813 additions and 76 deletions

View File

@@ -18,7 +18,8 @@
"dependencies": { "dependencies": {
"@tm/core": "*", "@tm/core": "*",
"chalk": "5.6.2", "chalk": "5.6.2",
"boxen": "^8.0.1" "boxen": "^8.0.1",
"ora": "^8.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",

View File

@@ -0,0 +1,197 @@
import chalk from 'chalk';
import boxen from 'boxen';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
/**
* Parameters for the expand bridge function
*/
export interface ExpandBridgeParams {
/** Task ID (can be numeric "1" or alphanumeric "TAS-49") */
taskId: string | number;
/** Number of subtasks to generate (optional) */
numSubtasks?: number;
/** Whether to use research AI */
useResearch?: boolean;
/** Additional context for generation */
additionalContext?: string;
/** Force regeneration even if subtasks exist */
force?: boolean;
/** Project root directory */
projectRoot: string;
/** Optional tag for task organization */
tag?: string;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: 'text' | 'json';
/** Logging function */
report: (level: string, ...args: unknown[]) => void;
}
/**
* Result returned when API storage handles the expansion
*/
export interface RemoteExpandResult {
success: boolean;
taskId: string | number;
message: string;
telemetryData: null;
tagInfo: null;
}
/**
* Shared bridge function for expand-task command.
* Checks if using API storage and delegates to remote AI service if so.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryExpandViaRemote(
params: ExpandBridgeParams
): Promise<RemoteExpandResult | null> {
const {
taskId,
numSubtasks,
useResearch = false,
additionalContext,
force = false,
projectRoot,
tag,
isMCP = false,
outputFormat = 'text',
report
} = params;
let tmCore: TmCore;
try {
tmCore = await createTmCore({
projectPath: projectRoot || process.cwd()
});
} catch (tmCoreError) {
const errorMessage =
tmCoreError instanceof Error ? tmCoreError.message : String(tmCoreError);
report(
'warn',
`TmCore check failed, falling back to file-based expansion: ${errorMessage}`
);
// Return null to signal fall-through to file storage logic
return null;
}
// Check if we're using API storage (use resolved storage type, not config)
const storageType = tmCore.tasks.getStorageType();
if (storageType !== 'api') {
// Not API storage - signal caller to fall through to file-based logic
report(
'debug',
`Using file storage - processing expansion locally for task ${taskId}`
);
return null;
}
// API STORAGE PATH: Delegate to remote AI service
report('info', `Delegating expansion to Hamster for task ${taskId}`);
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
const showDebug = process.env.TM_DEBUG === '1';
const contextPreview =
showDebug && additionalContext
? `${additionalContext.substring(0, 60)}${additionalContext.length > 60 ? '...' : ''}`
: additionalContext
? '[provided]'
: '[none]';
console.log(
boxen(
chalk.blue.bold(`Expanding Task via Hamster`) +
'\n\n' +
chalk.white(`Task ID: ${taskId}`) +
'\n' +
chalk.white(`Subtasks: ${numSubtasks || 'auto'}`) +
'\n' +
chalk.white(`Use Research: ${useResearch ? 'yes' : 'no'}`) +
'\n' +
chalk.white(`Force: ${force ? 'yes' : 'no'}`) +
'\n' +
chalk.white(`Context: ${contextPreview}`),
{
padding: 1,
borderColor: 'blue',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
}
)
);
}
const spinner =
!isMCP && outputFormat === 'text'
? ora({ text: 'Expanding task on Hamster...', color: 'cyan' }).start()
: null;
try {
// Call the API storage method which handles the remote expansion
const result = await tmCore.tasks.expand(String(taskId), tag, {
numSubtasks,
useResearch,
additionalContext,
force
});
if (spinner) {
spinner.succeed('Task expansion queued successfully');
}
if (outputFormat === 'text') {
// Build message conditionally based on result
let messageLines = [
chalk.green(`Successfully queued expansion for task ${taskId}`),
'',
chalk.white('The task expansion has been queued on Hamster'),
chalk.white('Subtasks will be generated in the background.')
];
// Add task link if available
if (result?.taskLink) {
messageLines.push('');
messageLines.push(
chalk.white('View task: ') + chalk.blue.underline(result.taskLink)
);
}
// Always add CLI alternative
messageLines.push('');
messageLines.push(
chalk.dim(`Or run: ${chalk.yellow(`task-master show ${taskId}`)}`)
);
console.log(
boxen(messageLines.join('\n'), {
padding: 1,
borderColor: 'green',
borderStyle: 'round'
})
);
}
// Return success result - signals that we handled it
return {
success: true,
taskId: taskId,
message: result?.message || 'Task expansion queued via remote AI service',
telemetryData: null,
tagInfo: null
};
} catch (expandError) {
if (spinner) {
spinner.fail('Expansion failed');
}
// tm-core already formatted the error properly, just re-throw
throw expandError;
}
}

View File

@@ -14,3 +14,9 @@ export {
type UpdateBridgeParams, type UpdateBridgeParams,
type RemoteUpdateResult type RemoteUpdateResult
} from './update-bridge.js'; } from './update-bridge.js';
export {
tryExpandViaRemote,
type ExpandBridgeParams,
type RemoteExpandResult
} from './expand-bridge.js';

View File

@@ -1,5 +1,6 @@
import chalk from 'chalk'; import chalk from 'chalk';
import boxen from 'boxen'; import boxen from 'boxen';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core'; import { createTmCore, type TmCore } from '@tm/core';
/** /**
@@ -120,18 +121,10 @@ export async function tryUpdateViaRemote(
); );
} }
let loadingIndicator: NodeJS.Timeout | null = null; const spinner =
if (!isMCP && outputFormat === 'text') { !isMCP && outputFormat === 'text'
// Simple loading indicator simulation (replace with actual startLoadingIndicator if available) ? ora({ text: 'Updating task on Hamster...', color: 'cyan' }).start()
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; : null;
let frameIndex = 0;
loadingIndicator = setInterval(() => {
process.stdout.write(
`\r${frames[frameIndex]} Updating task on Hamster...`
);
frameIndex = (frameIndex + 1) % frames.length;
}, 80);
}
try { try {
// Call the API storage method which handles the remote update // Call the API storage method which handles the remote update
@@ -139,9 +132,8 @@ export async function tryUpdateViaRemote(
mode mode
}); });
if (loadingIndicator) { if (spinner) {
clearInterval(loadingIndicator); spinner.succeed('Task updated successfully');
process.stdout.write('\r✓ Task updated successfully.\n');
} }
if (outputFormat === 'text') { if (outputFormat === 'text') {
@@ -172,9 +164,8 @@ export async function tryUpdateViaRemote(
tagInfo: null tagInfo: null
}; };
} catch (updateError) { } catch (updateError) {
if (loadingIndicator) { if (spinner) {
clearInterval(loadingIndicator); spinner.fail('Update failed');
process.stdout.write('\r✗ Update failed.\n');
} }
// tm-core already formatted the error properly, just re-throw // tm-core already formatted the error properly, just re-throw

View File

@@ -4,6 +4,7 @@
*/ */
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js'; import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
import type { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js';
/** /**
* Options for loading tasks from storage * Options for loading tasks from storage
@@ -92,6 +93,28 @@ export interface IStorage {
options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' }
): Promise<void>; ): Promise<void>;
/**
* Expand task into subtasks using AI-powered generation
* @param taskId - ID of the task to expand
* @param tag - Optional tag context for the task
* @param options - Optional expansion options
* @param options.numSubtasks - Number of subtasks to generate
* @param options.useResearch - Whether to use research capabilities
* @param options.additionalContext - Additional context for generation
* @param options.force - Force regeneration even if subtasks exist
* @returns ExpandTaskResult for API storage, void for file storage
*/
expandTaskWithPrompt(
taskId: string,
tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult | void>;
/** /**
* Update task or subtask status by ID * Update task or subtask status by ID
* @param taskId - ID of the task or subtask (e.g., "1" or "1.2") * @param taskId - ID of the task or subtask (e.g., "1" or "1.2")
@@ -260,6 +283,16 @@ export abstract class BaseStorage implements IStorage {
tag?: string, tag?: string,
options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' } options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' }
): Promise<void>; ): Promise<void>;
abstract expandTaskWithPrompt(
taskId: string,
tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult | void>;
abstract updateTaskStatus( abstract updateTaskStatus(
taskId: string, taskId: string,
newStatus: TaskStatus, newStatus: TaskStatus,

View File

@@ -69,11 +69,13 @@ export class TaskMapper {
createdAt: subtask.created_at, createdAt: subtask.created_at,
updatedAt: subtask.updated_at, updatedAt: subtask.updated_at,
assignee: subtask.assignee_id || undefined, assignee: subtask.assignee_id || undefined,
complexity: subtask.complexity ?? undefined complexity: subtask.complexity ?? undefined,
databaseId: subtask.id // Include the actual database UUID
})); }));
return { return {
id: dbTask.display_id || dbTask.id, // Use display_id if available id: dbTask.display_id || dbTask.id, // Use display_id if available
databaseId: dbTask.id, // Include the actual database UUID
title: dbTask.title, title: dbTask.title,
description: dbTask.description || '', description: dbTask.description || '',
status: this.mapStatus(dbTask.status), status: this.mapStatus(dbTask.status),

View File

@@ -73,6 +73,9 @@ export interface Task {
tags?: string[]; tags?: string[];
assignee?: string; assignee?: string;
// Database UUID (for API calls that need the actual UUID instead of display_id)
databaseId?: string;
// Complexity analysis (from complexity report) // Complexity analysis (from complexity report)
// Can be either enum ('simple' | 'moderate' | 'complex' | 'very-complex') or numeric score (1-10) // Can be either enum ('simple' | 'moderate' | 'complex' | 'very-complex') or numeric score (1-10)
complexity?: TaskComplexity | number; complexity?: TaskComplexity | number;

View File

@@ -423,7 +423,7 @@ export class ExportService {
); );
} else { } else {
// Direct Supabase approach is no longer supported // Direct Supabase approach is no longer supported
// The extractTasks method has been removed from SupabaseTaskRepository // The extractTasks method has been removed from SupabaseRepository
// as we now exclusively use the API endpoint for exports // as we now exclusively use the API endpoint for exports
throw new Error( throw new Error(
'Export API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable to enable task export.' 'Export API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable to enable task export.'

View File

@@ -0,0 +1,255 @@
/**
* @fileoverview Task Expansion Service
* Core service for expanding tasks into subtasks using AI
*/
import { z } from 'zod';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../../storage/utils/api-client.js';
import { getLogger } from '../../../common/logger/factory.js';
/**
* Response from the expand task API endpoint (202 Accepted)
*/
interface ExpandTaskResponse {
message: string;
taskId: string;
queued: boolean;
jobId: string;
}
/**
* Result returned to the caller with expansion details
*/
export interface ExpandTaskResult {
/** Success message */
message: string;
/** Task ID (display_id like HAM-4) */
taskId: string;
/** Whether the job was queued successfully */
queued: boolean;
/** Background job ID for tracking */
jobId: string;
/** Direct link to view the task in the UI */
taskLink: string;
}
/**
* Options for task expansion
*/
export interface ExpandTaskOptions {
/** Number of subtasks to generate */
numSubtasks?: number;
/** Use research model for expansion */
useResearch?: boolean;
/** Additional context for AI generation */
additionalContext?: string;
/** Force expansion even if subtasks exist */
force?: boolean;
}
/**
* Auth context with a guaranteed briefId
*/
type ContextWithBrief = NonNullable<
ReturnType<typeof AuthManager.prototype.getContext>
> & { briefId: string };
/**
* TaskExpansionService handles AI-powered task expansion
*/
export class TaskExpansionService {
private readonly repository: TaskRepository;
private readonly projectId: string;
private readonly apiClient: ApiClient;
private readonly authManager: AuthManager;
private readonly logger = getLogger('TaskExpansionService');
constructor(
repository: TaskRepository,
projectId: string,
apiClient: ApiClient,
authManager: AuthManager
) {
this.repository = repository;
this.projectId = projectId;
this.apiClient = apiClient;
this.authManager = authManager;
}
/**
* Expand task into subtasks with AI-powered generation
* Sends task to backend for server-side AI processing
* @returns Expansion result with job details and task link
*/
async expandTask(
taskId: string,
options?: ExpandTaskOptions
): Promise<ExpandTaskResult> {
try {
// Get brief context from AuthManager
const context = this.ensureBriefSelected('expandTask');
// Get the task being expanded to extract existing subtasks
const task = await this.repository.getTask(this.projectId, taskId);
if (!task) {
throw new TaskMasterError(
`Task ${taskId} not found`,
ERROR_CODES.VALIDATION_ERROR,
{
operation: 'expandTask',
taskId,
userMessage: `Task ${taskId} isn't available in the current project.`
}
);
}
// Get brief information for enriched context
const brief = await this.repository.getBrief(context.briefId);
// Build brief context payload with brief data if available
const briefContext = {
title: brief?.name || context.briefName || context.briefId,
description: brief?.description || undefined,
status: brief?.status || 'active'
};
// Get all tasks for context (optional but helpful for AI)
const allTasks = await this.repository.getTasks(this.projectId);
// Build the payload according to ExpandTaskContextSchema
const payload = {
briefContext,
allTasks,
existingSubtasks: task.subtasks || [],
enrichedContext: options?.additionalContext
};
// Build query params for options that aren't part of the context
const queryParams = new URLSearchParams();
if (options?.numSubtasks !== undefined) {
queryParams.set('numSubtasks', options.numSubtasks.toString());
}
if (options?.useResearch !== undefined) {
queryParams.set('useResearch', options.useResearch.toString());
}
if (options?.force !== undefined) {
queryParams.set('force', options.force.toString());
}
// Validate that task has a database UUID (required for API calls)
if (!task.databaseId) {
throw new TaskMasterError(
`Task ${taskId} is missing a database ID. Task expansion requires tasks to be synced with the remote database.`,
ERROR_CODES.VALIDATION_ERROR,
{
operation: 'expandTask',
taskId,
userMessage:
'This task has not been synced with the remote database. Please ensure the task is saved remotely before attempting expansion.'
}
);
}
// Validate UUID format using Zod
const uuidSchema = z.uuid();
const validation = uuidSchema.safeParse(task.databaseId);
if (!validation.success) {
throw new TaskMasterError(
`Task ${taskId} has an invalid database ID format: ${task.databaseId}`,
ERROR_CODES.VALIDATION_ERROR,
{
operation: 'expandTask',
taskId,
databaseId: task.databaseId,
userMessage:
'The task database ID is not in valid UUID format. This may indicate data corruption.'
}
);
}
// Use validated databaseId (UUID) for API calls
const taskUuid = task.databaseId;
const url = `/ai/api/v1/tasks/${taskUuid}/subtasks/generate${
queryParams.toString() ? `?${queryParams.toString()}` : ''
}`;
const result = await this.apiClient.post<ExpandTaskResponse>(
url,
payload
);
// Get base URL for task link
const baseUrl =
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN ||
'http://localhost:8080';
const taskLink = `${baseUrl}/home/hamster/briefs/${context.briefId}/task/${taskUuid}`;
// Log success with job details and task link
this.logger.info(`✓ Task expansion queued for ${taskId}`);
this.logger.info(` Job ID: ${result.jobId}`);
this.logger.info(` ${result.message}`);
this.logger.info(` View task: ${taskLink}`);
return {
...result,
taskLink
};
} catch (error) {
// If it's already a TaskMasterError, just add context and re-throw
if (error instanceof TaskMasterError) {
throw error.withContext({
operation: 'expandTask',
taskId,
numSubtasks: options?.numSubtasks,
useResearch: options?.useResearch
});
}
// For other errors, wrap them
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new TaskMasterError(
errorMessage,
ERROR_CODES.STORAGE_ERROR,
{
operation: 'expandTask',
taskId,
numSubtasks: options?.numSubtasks,
useResearch: options?.useResearch
},
error as Error
);
}
}
/**
* Ensure a brief is selected in the current context
* @returns The current auth context with a valid briefId
*/
private ensureBriefSelected(operation: string): ContextWithBrief {
const context = this.authManager.getContext();
if (!context?.briefId) {
throw new TaskMasterError(
'No brief selected',
ERROR_CODES.NO_BRIEF_SELECTED,
{
operation,
userMessage:
'No brief selected. Please select a brief first using: tm context brief <brief-id> or tm context brief <brief-url>'
}
);
}
return context as ContextWithBrief;
}
}

View File

@@ -20,11 +20,15 @@ import {
TaskMasterError TaskMasterError
} from '../../../common/errors/task-master-error.js'; } from '../../../common/errors/task-master-error.js';
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js'; import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import { SupabaseTaskRepository } from '../../tasks/repositories/supabase/index.js'; import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { AuthManager } from '../../auth/managers/auth-manager.js'; import { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../utils/api-client.js'; import { ApiClient } from '../utils/api-client.js';
import { getLogger } from '../../../common/logger/factory.js'; import { getLogger } from '../../../common/logger/factory.js';
import {
ExpandTaskResult,
TaskExpansionService
} from '../../integration/services/task-expansion.service.js';
/** /**
* API storage configuration * API storage configuration
@@ -77,6 +81,7 @@ export class ApiStorage implements IStorage {
private initialized = false; private initialized = false;
private tagsCache: Map<string, TaskTag> = new Map(); private tagsCache: Map<string, TaskTag> = new Map();
private apiClient?: ApiClient; private apiClient?: ApiClient;
private expansionService?: TaskExpansionService;
private readonly logger = getLogger('ApiStorage'); private readonly logger = getLogger('ApiStorage');
constructor(config: ApiStorageConfig) { constructor(config: ApiStorageConfig) {
@@ -86,9 +91,9 @@ export class ApiStorage implements IStorage {
if (config.repository) { if (config.repository) {
this.repository = config.repository; this.repository = config.repository;
} else if (config.supabaseClient) { } else if (config.supabaseClient) {
// TODO: SupabaseTaskRepository doesn't implement all TaskRepository methods yet // TODO: SupabaseRepository doesn't implement all TaskRepository methods yet
// Cast for now until full implementation is complete // Cast for now until full implementation is complete
this.repository = new SupabaseTaskRepository( this.repository = new SupabaseRepository(
config.supabaseClient config.supabaseClient
) as unknown as TaskRepository; ) as unknown as TaskRepository;
} else { } else {
@@ -601,6 +606,26 @@ export class ApiStorage implements IStorage {
} }
} }
/**
* Expand task into subtasks with AI-powered generation
* Sends task to backend for server-side AI processing
*/
async expandTaskWithPrompt(
taskId: string,
_tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult> {
await this.ensureInitialized();
const expansionService = this.getExpansionService();
return await expansionService.expandTask(taskId, options);
}
/** /**
* Update task or subtask status by ID - for API storage * Update task or subtask status by ID - for API storage
*/ */
@@ -925,6 +950,25 @@ export class ApiStorage implements IStorage {
return this.apiClient; return this.apiClient;
} }
/**
* Get or create TaskExpansionService instance
*/
private getExpansionService(): TaskExpansionService {
if (!this.expansionService) {
const apiClient = this.getApiClient();
const authManager = AuthManager.getInstance();
this.expansionService = new TaskExpansionService(
this.repository,
this.projectId,
apiClient,
authManager
);
}
return this.expansionService;
}
/** /**
* Retry an operation with exponential backoff * Retry an operation with exponential backoff
*/ */

View File

@@ -396,6 +396,26 @@ export class FileStorage implements IStorage {
); );
} }
/**
* Expand task into subtasks with AI-powered generation
* For file storage, this should NOT be called - client must handle AI processing first
*/
async expandTaskWithPrompt(
_taskId: string,
_tag?: string,
_options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<void> {
throw new Error(
'File storage does not support expandTaskWithPrompt. ' +
'Client-side AI logic must process the expansion before calling updateTask().'
);
}
/** /**
* Update task or subtask status by ID - handles file storage logic with parent/subtask relationships * Update task or subtask status by ID - handles file storage logic with parent/subtask relationships
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* Supabase repository implementations * Supabase repository implementations
*/ */
export { SupabaseTaskRepository } from './supabase-task-repository.js'; export { SupabaseRepository } from './supabase-repository.js';
export { DependencyFetcher } from './dependency-fetcher.js'; export { DependencyFetcher } from './dependency-fetcher.js';

View File

@@ -9,6 +9,7 @@ import {
TaskDatabaseUpdate TaskDatabaseUpdate
} from '../../../../common/types/repository-types.js'; } from '../../../../common/types/repository-types.js';
import { LoadTasksOptions } from '../../../../common/interfaces/storage.interface.js'; import { LoadTasksOptions } from '../../../../common/interfaces/storage.interface.js';
import { Brief } from '../task-repository.interface.js';
import { z } from 'zod'; import { z } from 'zod';
// Zod schema for task status validation // Zod schema for task status validation
@@ -34,7 +35,7 @@ const TaskUpdateSchema = z
}) })
.partial(); .partial();
export class SupabaseTaskRepository { export class SupabaseRepository {
private dependencyFetcher: DependencyFetcher; private dependencyFetcher: DependencyFetcher;
private authManager: AuthManager; private authManager: AuthManager;
@@ -152,6 +153,50 @@ export class SupabaseTaskRepository {
); );
} }
/**
* Get brief information by ID
* Note: This doesn't use getBriefIdOrThrow() because we may need to fetch
* briefs other than the current context brief
*/
async getBrief(briefId: string): Promise<Brief | null> {
const { data, error } = await this.supabase
.from('brief')
.select(`
*,
document:document_id (
id,
title,
description
)
`)
.eq('id', briefId)
.single();
if (error) {
if (error.code === 'PGRST116') {
return null; // Not found
}
throw new Error(`Failed to fetch brief: ${error.message}`);
}
if (!data) {
return null;
}
// Extract document data if available
const document = data.document as any;
// Map database fields to Brief interface
return {
id: data.id,
accountId: data.account_id,
createdAt: data.created_at,
name: document?.title || undefined,
description: document?.description || undefined,
status: data.status || undefined
};
}
async updateTask( async updateTask(
projectId: string, projectId: string,
taskId: string, taskId: string,

View File

@@ -1,6 +1,18 @@
import { Task, TaskTag } from '../../../common/types/index.js'; import { Task, TaskTag } from '../../../common/types/index.js';
import { LoadTasksOptions } from '../../../common/interfaces/storage.interface.js'; import { LoadTasksOptions } from '../../../common/interfaces/storage.interface.js';
/**
* Brief information
*/
export interface Brief {
id: string;
accountId: string;
createdAt: string;
name?: string;
description?: string;
status?: string;
}
export interface TaskRepository { export interface TaskRepository {
// Task operations // Task operations
getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>; getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>;
@@ -13,6 +25,9 @@ export interface TaskRepository {
): Promise<Task>; ): Promise<Task>;
deleteTask(projectId: string, taskId: string): Promise<void>; deleteTask(projectId: string, taskId: string): Promise<void>;
// Brief operations
getBrief(briefId: string): Promise<Brief | null>;
// Tag operations // Tag operations
getTags(projectId: string): Promise<TaskTag[]>; getTags(projectId: string): Promise<TaskTag[]>;
getTag(projectId: string, tagName: string): Promise<TaskTag | null>; getTag(projectId: string, tagName: string): Promise<TaskTag | null>;

View File

@@ -15,6 +15,7 @@ import { StorageFactory } from '../../storage/services/storage-factory.js';
import { TaskEntity } from '../entities/task.entity.js'; import { TaskEntity } from '../entities/task.entity.js';
import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js'; import { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/factory.js'; import { getLogger } from '../../../common/logger/factory.js';
import type { ExpandTaskResult } from '../../integration/services/task-expansion.service.js';
/** /**
* Result returned by getTaskList * Result returned by getTaskList
@@ -632,6 +633,76 @@ export class TaskService {
} }
} }
/**
* Expand a task into subtasks using AI-powered generation
* @param taskId - Task ID to expand (supports numeric and alphanumeric IDs)
* @param tag - Optional tag context
* @param options - Optional expansion options
* @param options.numSubtasks - Number of subtasks to generate
* @param options.useResearch - Use research AI for generation
* @param options.additionalContext - Additional context for generation
* @param options.force - Force regeneration even if subtasks exist
* @returns ExpandTaskResult when using API storage, void for file storage
*/
async expandTaskWithPrompt(
taskId: string | number,
tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult | void> {
// Ensure we have storage
if (!this.storage) {
throw new TaskMasterError(
'Storage not initialized',
ERROR_CODES.STORAGE_ERROR
);
}
// Auto-initialize if needed
if (!this.initialized) {
await this.initialize();
}
// Use provided tag or get active tag
const activeTag = tag || this.getActiveTag();
const taskIdStr = String(taskId);
try {
// AI-powered expansion - send to storage layer
// API storage: sends request to backend for server-side AI processing
// File storage: must use client-side AI logic before calling this
return await this.storage.expandTaskWithPrompt(
taskIdStr,
activeTag,
options
);
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError
) {
throw error;
}
throw new TaskMasterError(
`Failed to expand task ${taskId}`,
ERROR_CODES.STORAGE_ERROR,
{
operation: 'expandTaskWithPrompt',
resource: 'task',
taskId: taskIdStr,
tag: activeTag,
numSubtasks: options?.numSubtasks
},
error as Error
);
}
}
/** /**
* Update task status - delegates to storage layer which handles storage-specific logic * Update task status - delegates to storage layer which handles storage-specific logic
*/ */

View File

@@ -22,6 +22,7 @@ import type {
PreflightResult PreflightResult
} from './services/preflight-checker.service.js'; } from './services/preflight-checker.service.js';
import type { TaskValidationResult } from './services/task-loader.service.js'; import type { TaskValidationResult } from './services/task-loader.service.js';
import type { ExpandTaskResult } from '../integration/services/task-expansion.service.js';
/** /**
* Tasks Domain - Unified API for all task operations * Tasks Domain - Unified API for all task operations
@@ -151,6 +152,23 @@ export class TasksDomain {
return this.taskService.updateTaskWithPrompt(taskId, prompt, tag, options); return this.taskService.updateTaskWithPrompt(taskId, prompt, tag, options);
} }
/**
* Expand task into subtasks using AI
* @returns ExpandTaskResult when using API storage, void for file storage
*/
async expand(
taskId: string | number,
tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult | void> {
return this.taskService.expandTaskWithPrompt(taskId, tag, options);
}
/** /**
* Update task status * Update task status
*/ */

View File

@@ -0,0 +1,35 @@
import { isSilentMode, log as consoleLog } from './utils.js';
import { getDebugFlag } from './config-manager.js';
/**
* Create a unified logger and report function for bridge operations
* Handles both MCP and CLI contexts consistently
*
* @param {Object} mcpLog - Optional MCP logger object
* @param {Object} [session] - Optional session object for debug flag checking
* @returns {Object} Object containing logger, report function, and isMCP flag
*/
export function createBridgeLogger(mcpLog, session) {
const isMCP = !!mcpLog;
// Create logger that works in both contexts
const logger = mcpLog || {
info: (msg) => !isSilentMode() && consoleLog('info', msg),
warn: (msg) => !isSilentMode() && consoleLog('warn', msg),
error: (msg) => !isSilentMode() && consoleLog('error', msg),
debug: (msg) =>
!isSilentMode() && getDebugFlag(session) && consoleLog('debug', msg)
};
// Create report function compatible with bridge
const report = (level, ...args) => {
if (isMCP) {
if (typeof logger[level] === 'function') logger[level](...args);
else logger.info(...args);
} else if (!isSilentMode()) {
consoleLog(level, ...args);
}
};
return { logger, report, isMCP };
}

View File

@@ -1,13 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path';
import { import { readJSON, writeJSON } from '../utils.js';
getTagAwareFilePath,
isSilentMode,
log,
readJSON,
writeJSON
} from '../utils.js';
import { import {
displayAiUsageSummary, displayAiUsageSummary,
@@ -20,13 +13,15 @@ import { generateObjectService } from '../ai-services-unified.js';
import { import {
getDefaultSubtasks, getDefaultSubtasks,
getDebugFlag, hasCodebaseAnalysis,
hasCodebaseAnalysis getDebugFlag
} from '../config-manager.js'; } from '../config-manager.js';
import { getPromptManager } from '../prompt-manager.js'; import { getPromptManager } from '../prompt-manager.js';
import { findProjectRoot, flattenTasksWithSubtasks } from '../utils.js'; import { findProjectRoot, flattenTasksWithSubtasks } from '../utils.js';
import { ContextGatherer } from '../utils/contextGatherer.js'; import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { tryExpandViaRemote } from '@tm/bridge';
import { createBridgeLogger } from '../bridge-utils.js';
/** /**
* Expand a task into subtasks using the unified AI service (generateObjectService). * Expand a task into subtasks using the unified AI service (generateObjectService).
@@ -68,20 +63,35 @@ async function expandTask(
// Determine projectRoot: Use from context if available, otherwise derive from tasksPath // Determine projectRoot: Use from context if available, otherwise derive from tasksPath
const projectRoot = contextProjectRoot || findProjectRoot(tasksPath); const projectRoot = contextProjectRoot || findProjectRoot(tasksPath);
// Use mcpLog if available, otherwise use the default console log wrapper // Create unified logger and report function
const logger = mcpLog || { const { logger, report, isMCP } = createBridgeLogger(mcpLog, session);
info: (msg) => !isSilentMode() && log('info', msg),
warn: (msg) => !isSilentMode() && log('warn', msg),
error: (msg) => !isSilentMode() && log('error', msg),
debug: (msg) =>
!isSilentMode() && getDebugFlag(session) && log('debug', msg) // Use getDebugFlag
};
if (mcpLog) { if (isMCP) {
logger.info(`expandTask called with context: session=${!!session}`); logger.info(`expandTask called with context: session=${!!session}`);
} }
try { try {
// --- BRIDGE: Try remote expansion first (API storage) ---
const remoteResult = await tryExpandViaRemote({
taskId,
numSubtasks,
useResearch,
additionalContext,
force,
projectRoot,
tag,
isMCP,
outputFormat,
report
});
// If remote handled it, return the result
if (remoteResult) {
return remoteResult;
}
// Otherwise fall through to file-based logic below
// --- End BRIDGE ---
// --- Task Loading/Filtering (Unchanged) --- // --- Task Loading/Filtering (Unchanged) ---
logger.info(`Reading tasks from ${tasksPath}`); logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath, projectRoot, tag); const data = readJSON(tasksPath, projectRoot, tag);
@@ -275,7 +285,7 @@ async function expandTask(
} }
const { systemPrompt, userPrompt: promptContent } = const { systemPrompt, userPrompt: promptContent } =
await promptManager.loadPrompt('expand-task', promptParams, variantKey); promptManager.loadPrompt('expand-task', promptParams, variantKey);
// Debug logging to identify the issue // Debug logging to identify the issue
logger.debug(`Selected variant: ${variantKey}`); logger.debug(`Selected variant: ${variantKey}`);

View File

@@ -4,11 +4,9 @@ import boxen from 'boxen';
import Table from 'cli-table3'; import Table from 'cli-table3';
import { import {
log as consoleLog,
readJSON, readJSON,
writeJSON, writeJSON,
truncate, truncate,
isSilentMode,
flattenTasksWithSubtasks, flattenTasksWithSubtasks,
findProjectRoot findProjectRoot
} from '../utils.js'; } from '../utils.js';
@@ -26,14 +24,15 @@ import {
} from '../ai-services-unified.js'; } from '../ai-services-unified.js';
import { COMMAND_SCHEMAS } from '../../../src/schemas/registry.js'; import { COMMAND_SCHEMAS } from '../../../src/schemas/registry.js';
import { import {
getDebugFlag,
isApiKeySet, isApiKeySet,
hasCodebaseAnalysis hasCodebaseAnalysis,
getDebugFlag
} from '../config-manager.js'; } from '../config-manager.js';
import { getPromptManager } from '../prompt-manager.js'; import { getPromptManager } from '../prompt-manager.js';
import { ContextGatherer } from '../utils/contextGatherer.js'; import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js'; import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { tryUpdateViaRemote } from '@tm/bridge'; import { tryUpdateViaRemote } from '@tm/bridge';
import { createBridgeLogger } from '../bridge-utils.js';
/** /**
* Update a task by ID with new information using the unified AI service. * Update a task by ID with new information using the unified AI service.
@@ -60,18 +59,7 @@ async function updateTaskById(
appendMode = false appendMode = false
) { ) {
const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context; const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context;
const logFn = mcpLog || consoleLog; const { report, isMCP } = createBridgeLogger(mcpLog, session);
const isMCP = !!mcpLog;
// Use report helper for logging
const report = (level, ...args) => {
if (isMCP) {
if (typeof logFn[level] === 'function') logFn[level](...args);
else logFn.info(...args);
} else if (!isSilentMode()) {
logFn(level, ...args);
}
};
try { try {
report('info', `Updating single task ${taskId} with prompt: "${prompt}"`); report('info', `Updating single task ${taskId} with prompt: "${prompt}"`);
@@ -294,7 +282,7 @@ async function updateTaskById(
let systemPrompt; let systemPrompt;
let userPrompt; let userPrompt;
try { try {
const promptResult = await promptManager.loadPrompt( const promptResult = promptManager.loadPrompt(
'update-task', 'update-task',
promptParams, promptParams,
variantKey variantKey
@@ -571,10 +559,8 @@ async function updateTaskById(
// ... helpful hints ... // ... helpful hints ...
if (getDebugFlag(session)) console.error(error); if (getDebugFlag(session)) console.error(error);
process.exit(1); process.exit(1);
} else {
throw error; // Re-throw for MCP
} }
return null; // Indicate failure in CLI case if process doesn't exit throw error; // Re-throw for MCP
// --- End General Error Handling --- // --- End General Error Handling ---
} }
} }

View File

@@ -363,7 +363,7 @@ jest.unstable_mockModule(
'../../../../../scripts/modules/prompt-manager.js', '../../../../../scripts/modules/prompt-manager.js',
() => ({ () => ({
getPromptManager: jest.fn().mockReturnValue({ getPromptManager: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockResolvedValue({ loadPrompt: jest.fn().mockReturnValue({
systemPrompt: 'Mocked system prompt', systemPrompt: 'Mocked system prompt',
userPrompt: 'Mocked user prompt' userPrompt: 'Mocked user prompt'
}) })

View File

@@ -162,7 +162,7 @@ jest.unstable_mockModule(
'../../../../../scripts/modules/prompt-manager.js', '../../../../../scripts/modules/prompt-manager.js',
() => ({ () => ({
getPromptManager: jest.fn().mockReturnValue({ getPromptManager: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockResolvedValue({ loadPrompt: jest.fn().mockReturnValue({
systemPrompt: 'Mocked system prompt', systemPrompt: 'Mocked system prompt',
userPrompt: 'Mocked user prompt' userPrompt: 'Mocked user prompt'
}) })
@@ -182,7 +182,12 @@ jest.unstable_mockModule('chalk', () => ({
), ),
green: jest.fn((text) => text), green: jest.fn((text) => text),
yellow: jest.fn((text) => text), yellow: jest.fn((text) => text),
bold: jest.fn((text) => text) red: jest.fn((text) => text),
blue: jest.fn((text) => text),
magenta: jest.fn((text) => text),
gray: jest.fn((text) => text),
bold: jest.fn((text) => text),
dim: jest.fn((text) => text)
} }
})); }));
@@ -702,7 +707,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: 'Generate exactly 5 subtasks for complexity report', systemPrompt: 'Generate exactly 5 subtasks for complexity report',
userPrompt: userPrompt:
'Please break this task into 5 parts\n\nUser provided context' 'Please break this task into 5 parts\n\nUser provided context'
@@ -1016,7 +1021,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: 'Mocked system prompt', systemPrompt: 'Mocked system prompt',
userPrompt: 'Mocked user prompt with context' userPrompt: 'Mocked user prompt with context'
}); });
@@ -1145,7 +1150,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: systemPrompt:
'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into an appropriate number of specific subtasks that can be implemented one by one.', 'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into an appropriate number of specific subtasks that can be implemented one by one.',
userPrompt: userPrompt:
@@ -1173,7 +1178,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: systemPrompt:
'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 5 specific subtasks that can be implemented one by one.', 'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 5 specific subtasks that can be implemented one by one.',
userPrompt: 'Break down this task into exactly 5 specific subtasks' userPrompt: 'Break down this task into exactly 5 specific subtasks'
@@ -1201,7 +1206,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: systemPrompt:
'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 4 specific subtasks that can be implemented one by one.', 'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 4 specific subtasks that can be implemented one by one.',
userPrompt: 'Break down this task into exactly 4 specific subtasks' userPrompt: 'Break down this task into exactly 4 specific subtasks'
@@ -1227,7 +1232,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: systemPrompt:
'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 6 specific subtasks that can be implemented one by one.', 'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 6 specific subtasks that can be implemented one by one.',
userPrompt: 'Break down this task into exactly 6 specific subtasks' userPrompt: 'Break down this task into exactly 6 specific subtasks'
@@ -1253,7 +1258,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import( const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js' '../../../../../scripts/modules/prompt-manager.js'
); );
const mockLoadPrompt = jest.fn().mockResolvedValue({ const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: systemPrompt:
'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 7 specific subtasks that can be implemented one by one.', 'You are an AI assistant helping with task breakdown for software development. You need to break down a high-level task into 7 specific subtasks that can be implemented one by one.',
userPrompt: 'Break down this task into exactly 7 specific subtasks' userPrompt: 'Break down this task into exactly 7 specific subtasks'