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": {
"@tm/core": "*",
"chalk": "5.6.2",
"boxen": "^8.0.1"
"boxen": "^8.0.1",
"ora": "^8.1.1"
},
"devDependencies": {
"@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 RemoteUpdateResult
} 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 boxen from 'boxen';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
/**
@@ -120,18 +121,10 @@ export async function tryUpdateViaRemote(
);
}
let loadingIndicator: NodeJS.Timeout | null = null;
if (!isMCP && outputFormat === 'text') {
// Simple loading indicator simulation (replace with actual startLoadingIndicator if available)
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let frameIndex = 0;
loadingIndicator = setInterval(() => {
process.stdout.write(
`\r${frames[frameIndex]} Updating task on Hamster...`
);
frameIndex = (frameIndex + 1) % frames.length;
}, 80);
}
const spinner =
!isMCP && outputFormat === 'text'
? ora({ text: 'Updating task on Hamster...', color: 'cyan' }).start()
: null;
try {
// Call the API storage method which handles the remote update
@@ -139,9 +132,8 @@ export async function tryUpdateViaRemote(
mode
});
if (loadingIndicator) {
clearInterval(loadingIndicator);
process.stdout.write('\r✓ Task updated successfully.\n');
if (spinner) {
spinner.succeed('Task updated successfully');
}
if (outputFormat === 'text') {
@@ -172,9 +164,8 @@ export async function tryUpdateViaRemote(
tagInfo: null
};
} catch (updateError) {
if (loadingIndicator) {
clearInterval(loadingIndicator);
process.stdout.write('\r✗ Update failed.\n');
if (spinner) {
spinner.fail('Update failed');
}
// 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 { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js';
/**
* Options for loading tasks from storage
@@ -92,6 +93,28 @@ export interface IStorage {
options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' }
): 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
* @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,
options?: { useResearch?: boolean; mode?: 'append' | 'update' | 'rewrite' }
): Promise<void>;
abstract expandTaskWithPrompt(
taskId: string,
tag?: string,
options?: {
numSubtasks?: number;
useResearch?: boolean;
additionalContext?: string;
force?: boolean;
}
): Promise<ExpandTaskResult | void>;
abstract updateTaskStatus(
taskId: string,
newStatus: TaskStatus,

View File

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

View File

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

View File

@@ -423,7 +423,7 @@ export class ExportService {
);
} else {
// 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
throw new Error(
'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
} from '../../../common/errors/task-master-error.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 { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../utils/api-client.js';
import { getLogger } from '../../../common/logger/factory.js';
import {
ExpandTaskResult,
TaskExpansionService
} from '../../integration/services/task-expansion.service.js';
/**
* API storage configuration
@@ -77,6 +81,7 @@ export class ApiStorage implements IStorage {
private initialized = false;
private tagsCache: Map<string, TaskTag> = new Map();
private apiClient?: ApiClient;
private expansionService?: TaskExpansionService;
private readonly logger = getLogger('ApiStorage');
constructor(config: ApiStorageConfig) {
@@ -86,9 +91,9 @@ export class ApiStorage implements IStorage {
if (config.repository) {
this.repository = config.repository;
} 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
this.repository = new SupabaseTaskRepository(
this.repository = new SupabaseRepository(
config.supabaseClient
) as unknown as TaskRepository;
} 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
*/
@@ -925,6 +950,25 @@ export class ApiStorage implements IStorage {
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
*/

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
*/

View File

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

View File

@@ -9,6 +9,7 @@ import {
TaskDatabaseUpdate
} from '../../../../common/types/repository-types.js';
import { LoadTasksOptions } from '../../../../common/interfaces/storage.interface.js';
import { Brief } from '../task-repository.interface.js';
import { z } from 'zod';
// Zod schema for task status validation
@@ -34,7 +35,7 @@ const TaskUpdateSchema = z
})
.partial();
export class SupabaseTaskRepository {
export class SupabaseRepository {
private dependencyFetcher: DependencyFetcher;
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(
projectId: string,
taskId: string,

View File

@@ -1,6 +1,18 @@
import { Task, TaskTag } from '../../../common/types/index.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 {
// Task operations
getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>;
@@ -13,6 +25,9 @@ export interface TaskRepository {
): Promise<Task>;
deleteTask(projectId: string, taskId: string): Promise<void>;
// Brief operations
getBrief(briefId: string): Promise<Brief | null>;
// Tag operations
getTags(projectId: string): Promise<TaskTag[]>;
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 { ERROR_CODES, TaskMasterError } from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/factory.js';
import type { ExpandTaskResult } from '../../integration/services/task-expansion.service.js';
/**
* 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
*/

View File

@@ -22,6 +22,7 @@ import type {
PreflightResult
} from './services/preflight-checker.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
@@ -151,6 +152,23 @@ export class TasksDomain {
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
*/

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 path from 'path';
import {
getTagAwareFilePath,
isSilentMode,
log,
readJSON,
writeJSON
} from '../utils.js';
import { readJSON, writeJSON } from '../utils.js';
import {
displayAiUsageSummary,
@@ -20,13 +13,15 @@ import { generateObjectService } from '../ai-services-unified.js';
import {
getDefaultSubtasks,
getDebugFlag,
hasCodebaseAnalysis
hasCodebaseAnalysis,
getDebugFlag
} from '../config-manager.js';
import { getPromptManager } from '../prompt-manager.js';
import { findProjectRoot, flattenTasksWithSubtasks } from '../utils.js';
import { ContextGatherer } from '../utils/contextGatherer.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).
@@ -68,20 +63,35 @@ async function expandTask(
// Determine projectRoot: Use from context if available, otherwise derive from tasksPath
const projectRoot = contextProjectRoot || findProjectRoot(tasksPath);
// Use mcpLog if available, otherwise use the default console log wrapper
const logger = mcpLog || {
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
};
// Create unified logger and report function
const { logger, report, isMCP } = createBridgeLogger(mcpLog, session);
if (mcpLog) {
if (isMCP) {
logger.info(`expandTask called with context: session=${!!session}`);
}
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) ---
logger.info(`Reading tasks from ${tasksPath}`);
const data = readJSON(tasksPath, projectRoot, tag);
@@ -275,7 +285,7 @@ async function expandTask(
}
const { systemPrompt, userPrompt: promptContent } =
await promptManager.loadPrompt('expand-task', promptParams, variantKey);
promptManager.loadPrompt('expand-task', promptParams, variantKey);
// Debug logging to identify the issue
logger.debug(`Selected variant: ${variantKey}`);

View File

@@ -4,11 +4,9 @@ import boxen from 'boxen';
import Table from 'cli-table3';
import {
log as consoleLog,
readJSON,
writeJSON,
truncate,
isSilentMode,
flattenTasksWithSubtasks,
findProjectRoot
} from '../utils.js';
@@ -26,14 +24,15 @@ import {
} from '../ai-services-unified.js';
import { COMMAND_SCHEMAS } from '../../../src/schemas/registry.js';
import {
getDebugFlag,
isApiKeySet,
hasCodebaseAnalysis
hasCodebaseAnalysis,
getDebugFlag
} from '../config-manager.js';
import { getPromptManager } from '../prompt-manager.js';
import { ContextGatherer } from '../utils/contextGatherer.js';
import { FuzzyTaskSearch } from '../utils/fuzzyTaskSearch.js';
import { tryUpdateViaRemote } from '@tm/bridge';
import { createBridgeLogger } from '../bridge-utils.js';
/**
* Update a task by ID with new information using the unified AI service.
@@ -60,18 +59,7 @@ async function updateTaskById(
appendMode = false
) {
const { session, mcpLog, projectRoot: providedProjectRoot, tag } = context;
const logFn = mcpLog || consoleLog;
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);
}
};
const { report, isMCP } = createBridgeLogger(mcpLog, session);
try {
report('info', `Updating single task ${taskId} with prompt: "${prompt}"`);
@@ -294,7 +282,7 @@ async function updateTaskById(
let systemPrompt;
let userPrompt;
try {
const promptResult = await promptManager.loadPrompt(
const promptResult = promptManager.loadPrompt(
'update-task',
promptParams,
variantKey
@@ -571,10 +559,8 @@ async function updateTaskById(
// ... helpful hints ...
if (getDebugFlag(session)) console.error(error);
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 ---
}
}

View File

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

View File

@@ -162,7 +162,7 @@ jest.unstable_mockModule(
'../../../../../scripts/modules/prompt-manager.js',
() => ({
getPromptManager: jest.fn().mockReturnValue({
loadPrompt: jest.fn().mockResolvedValue({
loadPrompt: jest.fn().mockReturnValue({
systemPrompt: 'Mocked system prompt',
userPrompt: 'Mocked user prompt'
})
@@ -182,7 +182,12 @@ jest.unstable_mockModule('chalk', () => ({
),
green: 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(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: 'Generate exactly 5 subtasks for complexity report',
userPrompt:
'Please break this task into 5 parts\n\nUser provided context'
@@ -1016,7 +1021,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
systemPrompt: 'Mocked system prompt',
userPrompt: 'Mocked user prompt with context'
});
@@ -1145,7 +1150,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
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.',
userPrompt:
@@ -1173,7 +1178,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
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.',
userPrompt: 'Break down this task into exactly 5 specific subtasks'
@@ -1201,7 +1206,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
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.',
userPrompt: 'Break down this task into exactly 4 specific subtasks'
@@ -1227,7 +1232,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
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.',
userPrompt: 'Break down this task into exactly 6 specific subtasks'
@@ -1253,7 +1258,7 @@ describe('expandTask', () => {
const { getPromptManager } = await import(
'../../../../../scripts/modules/prompt-manager.js'
);
const mockLoadPrompt = jest.fn().mockResolvedValue({
const mockLoadPrompt = jest.fn().mockReturnValue({
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.',
userPrompt: 'Break down this task into exactly 7 specific subtasks'