mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: add expand-task remote (#1384)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
197
packages/tm-bridge/src/expand-bridge.ts
Normal file
197
packages/tm-bridge/src/expand-bridge.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
35
scripts/modules/bridge-utils.js
Normal file
35
scripts/modules/bridge-utils.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 ---
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user