feat: initial tm-core pre-cleanup

This commit is contained in:
Ralph Khreish
2025-08-22 14:28:01 +02:00
parent d5c2acc8bf
commit 281f556203
22 changed files with 572 additions and 289 deletions

View File

@@ -8,7 +8,13 @@
},
"files": {
"include": ["src/**/*.ts", "tests/**/*.ts", "*.ts", "*.js", "*.json"],
"ignore": ["**/node_modules", "**/dist", "**/.git", "**/coverage", "**/*.d.ts"]
"ignore": [
"**/node_modules",
"**/dist",
"**/.git",
"**/coverage",
"**/*.d.ts"
]
},
"formatter": {
"enabled": true,

View File

@@ -0,0 +1,75 @@
/**
* @fileoverview Constants for Task Master Core
* Single source of truth for all constant values
*/
import type {
TaskStatus,
TaskPriority,
TaskComplexity
} from '../types/index.js';
/**
* Valid task status values
*/
export const TASK_STATUSES: readonly TaskStatus[] = [
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
] as const;
/**
* Valid task priority values
*/
export const TASK_PRIORITIES: readonly TaskPriority[] = [
'low',
'medium',
'high',
'critical'
] as const;
/**
* Valid task complexity values
*/
export const TASK_COMPLEXITIES: readonly TaskComplexity[] = [
'simple',
'moderate',
'complex',
'very-complex'
] as const;
/**
* Valid output formats for task display
*/
export const OUTPUT_FORMATS = ['text', 'json', 'compact'] as const;
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
/**
* Status icons for display
*/
export const STATUS_ICONS: Record<TaskStatus, string> = {
done: '✓',
'in-progress': '►',
blocked: '⭕',
pending: '○',
deferred: '⏸',
cancelled: '✗',
review: '👁'
} as const;
/**
* Status colors for display (using chalk color names)
*/
export const STATUS_COLORS: Record<TaskStatus, string> = {
pending: 'yellow',
'in-progress': 'blue',
done: 'green',
deferred: 'gray',
cancelled: 'red',
blocked: 'magenta',
review: 'cyan'
} as const;

View File

@@ -3,7 +3,12 @@
*/
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
import type { Subtask, Task, TaskPriority, TaskStatus } from '../types/index.js';
import type {
Subtask,
Task,
TaskPriority,
TaskStatus
} from '../types/index.js';
/**
* Task entity representing a task with business logic
@@ -64,11 +69,17 @@ export class TaskEntity implements Task {
}
if (!data.title || data.title.trim().length === 0) {
throw new TaskMasterError('Task title is required', ERROR_CODES.VALIDATION_ERROR);
throw new TaskMasterError(
'Task title is required',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!data.description || data.description.trim().length === 0) {
throw new TaskMasterError('Task description is required', ERROR_CODES.VALIDATION_ERROR);
throw new TaskMasterError(
'Task description is required',
ERROR_CODES.VALIDATION_ERROR
);
}
if (!this.isValidStatus(data.status)) {
@@ -184,7 +195,10 @@ export class TaskEntity implements Task {
*/
updateStatus(newStatus: TaskStatus): void {
if (!this.isValidStatus(newStatus)) {
throw new TaskMasterError(`Invalid status: ${newStatus}`, ERROR_CODES.VALIDATION_ERROR);
throw new TaskMasterError(
`Invalid status: ${newStatus}`,
ERROR_CODES.VALIDATION_ERROR
);
}
// Business rule: Cannot move from done to pending

View File

@@ -215,7 +215,14 @@ export class TaskMasterError extends Error {
private containsSensitiveInfo(obj: any): boolean {
if (typeof obj !== 'object' || obj === null) return false;
const sensitiveKeys = ['password', 'token', 'key', 'secret', 'auth', 'credential'];
const sensitiveKeys = [
'password',
'token',
'key',
'secret',
'auth',
'credential'
];
const objString = JSON.stringify(obj).toLowerCase();
return sensitiveKeys.some((key) => objString.includes(key));
@@ -297,7 +304,9 @@ export class TaskMasterError extends Error {
/**
* Create a new error with additional context
*/
public withContext(additionalContext: Partial<ErrorContext>): TaskMasterError {
public withContext(
additionalContext: Partial<ErrorContext>
): TaskMasterError {
return new TaskMasterError(
this.message,
this.code,

View File

@@ -17,11 +17,19 @@ export type * from './types/index';
// Re-export interfaces (types only to avoid conflicts)
export type * from './interfaces/index';
// Re-export constants
export * from './constants/index';
// Re-export providers
export * from './providers/index';
// Re-export storage (selectively to avoid conflicts)
export { FileStorage, ApiStorage, StorageFactory, type ApiStorageConfig } from './storage/index';
export {
FileStorage,
ApiStorage,
StorageFactory,
type ApiStorageConfig
} from './storage/index';
export { PlaceholderStorage, type StorageAdapter } from './storage/index';
// Re-export parser

View File

@@ -282,7 +282,10 @@ export abstract class BaseAIProvider implements IAIProvider {
}
// Abstract methods that must be implemented by concrete classes
abstract generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
abstract generateCompletion(
prompt: string,
options?: AIOptions
): Promise<AIResponse>;
abstract generateStreamingCompletion(
prompt: string,
options?: AIOptions
@@ -306,7 +309,9 @@ export abstract class BaseAIProvider implements IAIProvider {
const modelExists = availableModels.some((m) => m.id === model);
if (!modelExists) {
throw new Error(`Model "${model}" is not available for provider "${this.getName()}"`);
throw new Error(
`Model "${model}" is not available for provider "${this.getName()}"`
);
}
this.currentModel = model;
@@ -342,7 +347,11 @@ export abstract class BaseAIProvider implements IAIProvider {
* @param duration - Request duration in milliseconds
* @param success - Whether the request was successful
*/
protected updateUsageStats(response: AIResponse, duration: number, success: boolean): void {
protected updateUsageStats(
response: AIResponse,
duration: number,
success: boolean
): void {
if (!this.usageStats) return;
this.usageStats.totalRequests++;
@@ -361,15 +370,18 @@ export abstract class BaseAIProvider implements IAIProvider {
}
// Update average response time
const totalTime = this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
this.usageStats.averageResponseTime = (totalTime + duration) / this.usageStats.totalRequests;
const totalTime =
this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
this.usageStats.averageResponseTime =
(totalTime + duration) / this.usageStats.totalRequests;
// Update success rate
const successCount = Math.floor(
this.usageStats.successRate * (this.usageStats.totalRequests - 1)
);
const newSuccessCount = successCount + (success ? 1 : 0);
this.usageStats.successRate = newSuccessCount / this.usageStats.totalRequests;
this.usageStats.successRate =
newSuccessCount / this.usageStats.totalRequests;
this.usageStats.lastRequestAt = new Date().toISOString();
}

View File

@@ -40,7 +40,11 @@ export interface IStorage {
* @param tag - Optional tag context for the task
* @returns Promise that resolves when update is complete
*/
updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void>;
/**
* Delete a task by ID
@@ -173,7 +177,11 @@ export abstract class BaseStorage implements IStorage {
abstract loadTasks(tag?: string): Promise<Task[]>;
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
abstract updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
abstract updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void>;
abstract deleteTask(taskId: string, tag?: string): Promise<void>;
abstract exists(tag?: string): Promise<boolean>;
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;

View File

@@ -22,7 +22,9 @@ export interface TaskParser {
export class PlaceholderParser implements TaskParser {
async parse(content: string): Promise<PlaceholderTask[]> {
// Simple placeholder parsing logic
const lines = content.split('\n').filter((line) => line.trim().startsWith('-'));
const lines = content
.split('\n')
.filter((line) => line.trim().startsWith('-'));
return lines.map((line, index) => ({
id: `task-${index + 1}`,
title: line.trim().replace(/^-\s*/, ''),

View File

@@ -3,8 +3,15 @@
* Provides common functionality, error handling, and retry logic
*/
import { ERROR_CODES, TaskMasterError } from '../../errors/task-master-error.js';
import type { AIOptions, AIResponse, IAIProvider } from '../../interfaces/ai-provider.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../errors/task-master-error.js';
import type {
AIOptions,
AIResponse,
IAIProvider
} from '../../interfaces/ai-provider.interface.js';
// Constants for retry logic
const DEFAULT_MAX_RETRIES = 3;
@@ -67,7 +74,10 @@ export abstract class BaseProvider implements IAIProvider {
constructor(config: BaseProviderConfig) {
if (!config.apiKey) {
throw new TaskMasterError('API key is required', ERROR_CODES.AUTHENTICATION_ERROR);
throw new TaskMasterError(
'API key is required',
ERROR_CODES.AUTHENTICATION_ERROR
);
}
this.apiKey = config.apiKey;
this.model = config.model || this.getDefaultModel();
@@ -77,11 +87,17 @@ export abstract class BaseProvider implements IAIProvider {
* Template method for generating completions
* Handles validation, retries, and error handling
*/
async generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse> {
async generateCompletion(
prompt: string,
options?: AIOptions
): Promise<AIResponse> {
// Validate input
const validation = this.validateInput(prompt, options);
if (!validation.valid) {
throw new TaskMasterError(validation.error || 'Invalid input', ERROR_CODES.VALIDATION_ERROR);
throw new TaskMasterError(
validation.error || 'Invalid input',
ERROR_CODES.VALIDATION_ERROR
);
}
// Prepare request
@@ -94,7 +110,10 @@ export abstract class BaseProvider implements IAIProvider {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const startTime = Date.now();
const result = await this.generateCompletionInternal(prepared.prompt, prepared.options);
const result = await this.generateCompletionInternal(
prepared.prompt,
prepared.options
);
const duration = Date.now() - startTime;
return this.handleResponse(result, duration, prepared);
@@ -117,7 +136,10 @@ export abstract class BaseProvider implements IAIProvider {
/**
* Validate input prompt and options
*/
protected validateInput(prompt: string, options?: AIOptions): ValidationResult {
protected validateInput(
prompt: string,
options?: AIOptions
): ValidationResult {
// Validate prompt
if (!prompt || typeof prompt !== 'string') {
return { valid: false, error: 'Prompt must be a non-empty string' };
@@ -151,7 +173,10 @@ export abstract class BaseProvider implements IAIProvider {
*/
protected validateOptions(options: AIOptions): ValidationResult {
if (options.temperature !== undefined) {
if (options.temperature < MIN_TEMPERATURE || options.temperature > MAX_TEMPERATURE) {
if (
options.temperature < MIN_TEMPERATURE ||
options.temperature > MAX_TEMPERATURE
) {
return {
valid: false,
error: `Temperature must be between ${MIN_TEMPERATURE} and ${MAX_TEMPERATURE}`
@@ -160,7 +185,10 @@ export abstract class BaseProvider implements IAIProvider {
}
if (options.maxTokens !== undefined) {
if (options.maxTokens < MIN_MAX_TOKENS || options.maxTokens > MAX_MAX_TOKENS) {
if (
options.maxTokens < MIN_MAX_TOKENS ||
options.maxTokens > MAX_MAX_TOKENS
) {
return {
valid: false,
error: `Max tokens must be between ${MIN_MAX_TOKENS} and ${MAX_MAX_TOKENS}`
@@ -180,7 +208,10 @@ export abstract class BaseProvider implements IAIProvider {
/**
* Prepare request for processing
*/
protected prepareRequest(prompt: string, options?: AIOptions): PreparedRequest {
protected prepareRequest(
prompt: string,
options?: AIOptions
): PreparedRequest {
const defaultOptions = this.getDefaultOptions();
const mergedOptions = { ...defaultOptions, ...options };
@@ -203,8 +234,10 @@ export abstract class BaseProvider implements IAIProvider {
duration: number,
request: PreparedRequest
): AIResponse {
const inputTokens = result.inputTokens || this.calculateTokens(request.prompt);
const outputTokens = result.outputTokens || this.calculateTokens(result.content);
const inputTokens =
result.inputTokens || this.calculateTokens(request.prompt);
const outputTokens =
result.outputTokens || this.calculateTokens(result.content);
return {
content: result.content,
@@ -320,7 +353,8 @@ export abstract class BaseProvider implements IAIProvider {
* Calculate exponential backoff delay with jitter
*/
protected calculateBackoffDelay(attempt: number): number {
const exponentialDelay = BASE_RETRY_DELAY_MS * BACKOFF_MULTIPLIER ** (attempt - 1);
const exponentialDelay =
BASE_RETRY_DELAY_MS * BACKOFF_MULTIPLIER ** (attempt - 1);
const clampedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
// Add jitter to prevent thundering herd
@@ -394,11 +428,16 @@ export abstract class BaseProvider implements IAIProvider {
options?: AIOptions
): AsyncIterator<Partial<AIResponse>>;
abstract isAvailable(): Promise<boolean>;
abstract getProviderInfo(): import('../../interfaces/ai-provider.interface.js').ProviderInfo;
abstract getAvailableModels(): import('../../interfaces/ai-provider.interface.js').AIModel[];
abstract getProviderInfo(): import(
'../../interfaces/ai-provider.interface.js'
).ProviderInfo;
abstract getAvailableModels(): import(
'../../interfaces/ai-provider.interface.js'
).AIModel[];
abstract validateCredentials(): Promise<boolean>;
abstract getUsageStats(): Promise<
import('../../interfaces/ai-provider.interface.js').ProviderUsageStats | null
| import('../../interfaces/ai-provider.interface.js').ProviderUsageStats
| null
>;
abstract initialize(): Promise<void>;
abstract close(): Promise<void>;

View File

@@ -49,7 +49,7 @@ export class TaskService {
constructor(configManager: ConfigManager) {
this.configManager = configManager;
// Storage will be created during initialization
this.storage = null as any;
}
@@ -66,7 +66,7 @@ export class TaskService {
// Create storage based on configuration
const storageConfig = this.configManager.getStorageConfig();
const projectRoot = this.configManager.getProjectRoot();
this.storage = StorageFactory.create(
{ storage: storageConfig } as any,
projectRoot
@@ -74,7 +74,7 @@ export class TaskService {
// Initialize storage
await this.storage.initialize();
this.initialized = true;
}
@@ -103,11 +103,11 @@ export class TaskService {
}
// Convert back to plain objects
let tasks = filteredEntities.map(entity => entity.toJSON());
let tasks = filteredEntities.map((entity) => entity.toJSON());
// Handle subtasks option
if (options.includeSubtasks === false) {
tasks = tasks.map(task => ({
tasks = tasks.map((task) => ({
...task,
subtasks: []
}));
@@ -124,7 +124,7 @@ export class TaskService {
throw new TaskMasterError(
'Failed to get task list',
ERROR_CODES.INTERNAL_ERROR,
{
{
operation: 'getTaskList',
tag,
hasFilter: !!options.filter
@@ -138,28 +138,28 @@ export class TaskService {
* Get a single task by ID
*/
async getTask(taskId: string, tag?: string): Promise<Task | null> {
const result = await this.getTaskList({
const result = await this.getTaskList({
tag,
includeSubtasks: true
includeSubtasks: true
});
return result.tasks.find(t => t.id === taskId) || null;
return result.tasks.find((t) => t.id === taskId) || null;
}
/**
* Get tasks filtered by status
*/
async getTasksByStatus(
status: TaskStatus | TaskStatus[],
status: TaskStatus | TaskStatus[],
tag?: string
): Promise<Task[]> {
const statuses = Array.isArray(status) ? status : [status];
const result = await this.getTaskList({
tag,
filter: { status: statuses }
});
return result.tasks;
}
@@ -173,9 +173,9 @@ export class TaskService {
blocked: number;
storageType: 'file' | 'api';
}> {
const result = await this.getTaskList({
const result = await this.getTaskList({
tag,
includeSubtasks: true
includeSubtasks: true
});
const stats = {
@@ -188,22 +188,27 @@ export class TaskService {
// Initialize all statuses
const allStatuses: TaskStatus[] = [
'pending', 'in-progress', 'done',
'deferred', 'cancelled', 'blocked', 'review'
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
];
allStatuses.forEach(status => {
allStatuses.forEach((status) => {
stats.byStatus[status] = 0;
});
// Count tasks
result.tasks.forEach(task => {
result.tasks.forEach((task) => {
stats.byStatus[task.status]++;
if (task.subtasks && task.subtasks.length > 0) {
stats.withSubtasks++;
}
if (task.status === 'blocked') {
stats.blocked++;
}
@@ -225,21 +230,19 @@ export class TaskService {
// Find tasks with no dependencies or all dependencies satisfied
const completedIds = new Set(
result.tasks
.filter(t => t.status === 'done')
.map(t => t.id)
result.tasks.filter((t) => t.status === 'done').map((t) => t.id)
);
const availableTasks = result.tasks.filter(task => {
const availableTasks = result.tasks.filter((task) => {
if (task.status === 'done' || task.status === 'blocked') {
return false;
}
if (!task.dependencies || task.dependencies.length === 0) {
return true;
}
return task.dependencies.every(depId =>
return task.dependencies.every((depId) =>
completedIds.has(depId.toString())
);
});
@@ -259,10 +262,12 @@ export class TaskService {
* Apply filters to task entities
*/
private applyFilters(tasks: TaskEntity[], filter: TaskFilter): TaskEntity[] {
return tasks.filter(task => {
return tasks.filter((task) => {
// Status filter
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
const statuses = Array.isArray(filter.status)
? filter.status
: [filter.status];
if (!statuses.includes(task.status)) {
return false;
}
@@ -270,7 +275,9 @@ export class TaskService {
// Priority filter
if (filter.priority) {
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
const priorities = Array.isArray(filter.priority)
? filter.priority
: [filter.priority];
if (!priorities.includes(task.priority)) {
return false;
}
@@ -278,7 +285,10 @@ export class TaskService {
// Tags filter
if (filter.tags && filter.tags.length > 0) {
if (!task.tags || !filter.tags.some(tag => task.tags?.includes(tag))) {
if (
!task.tags ||
!filter.tags.some((tag) => task.tags?.includes(tag))
) {
return false;
}
}
@@ -292,8 +302,8 @@ export class TaskService {
// Complexity filter
if (filter.complexity) {
const complexities = Array.isArray(filter.complexity)
? filter.complexity
const complexities = Array.isArray(filter.complexity)
? filter.complexity
: [filter.complexity];
if (!task.complexity || !complexities.includes(task.complexity)) {
return false;
@@ -304,9 +314,11 @@ export class TaskService {
if (filter.search) {
const searchLower = filter.search.toLowerCase();
const inTitle = task.title.toLowerCase().includes(searchLower);
const inDescription = task.description.toLowerCase().includes(searchLower);
const inDescription = task.description
.toLowerCase()
.includes(searchLower);
const inDetails = task.details.toLowerCase().includes(searchLower);
if (!inTitle && !inDescription && !inDetails) {
return false;
}
@@ -353,4 +365,4 @@ export class TaskService {
async setActiveTag(tag: string): Promise<void> {
await this.configManager.setActiveTag(tag);
}
}
}

View File

@@ -3,7 +3,10 @@
* This provides storage via REST API instead of local file system
*/
import type { IStorage, StorageStats } from '../interfaces/storage.interface.js';
import type {
IStorage,
StorageStats
} from '../interfaces/storage.interface.js';
import type { Task, TaskMetadata } from '../types/index.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
@@ -45,7 +48,7 @@ export class ApiStorage implements IStorage {
constructor(config: ApiStorageConfig) {
this.validateConfig(config);
this.config = {
endpoint: config.endpoint.replace(/\/$/, ''), // Remove trailing slash
accessToken: config.accessToken,
@@ -111,7 +114,7 @@ export class ApiStorage implements IStorage {
*/
private async verifyConnection(): Promise<void> {
const response = await this.makeRequest<{ status: string }>('/health');
if (!response.success) {
throw new Error(`API health check failed: ${response.error}`);
}
@@ -124,7 +127,7 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();
try {
const endpoint = tag
const endpoint = tag
? `/projects/${this.config.projectId}/tasks?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/tasks`;
@@ -291,7 +294,9 @@ export class ApiStorage implements IStorage {
? `/projects/${this.config.projectId}/metadata?tag=${encodeURIComponent(tag)}`
: `/projects/${this.config.projectId}/metadata`;
const response = await this.makeRequest<{ metadata: TaskMetadata }>(endpoint);
const response = await this.makeRequest<{ metadata: TaskMetadata }>(
endpoint
);
if (!response.success) {
return null;
@@ -355,10 +360,10 @@ export class ApiStorage implements IStorage {
try {
// First load existing tasks
const existingTasks = await this.loadTasks(tag);
// Append new tasks
const allTasks = [...existingTasks, ...tasks];
// Save all tasks
await this.saveTasks(allTasks, tag);
} catch (error) {
@@ -374,20 +379,24 @@ export class ApiStorage implements IStorage {
/**
* Update a specific task
*/
async updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void> {
async updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void> {
await this.ensureInitialized();
try {
// Load the task
const task = await this.loadTask(taskId, tag);
if (!task) {
throw new Error(`Task ${taskId} not found`);
}
// Merge updates
const updatedTask = { ...task, ...updates, id: taskId };
// Save updated task
await this.saveTask(updatedTask, tag);
} catch (error) {
@@ -500,13 +509,15 @@ export class ApiStorage implements IStorage {
}
// Return stats or default values
return response.data?.stats || {
totalTasks: 0,
totalTags: 0,
storageSize: 0,
lastModified: new Date().toISOString(),
tagStats: []
};
return (
response.data?.stats || {
totalTasks: 0,
totalTags: 0,
storageSize: 0,
lastModified: new Date().toISOString(),
tagStats: []
}
);
} catch (error) {
throw new TaskMasterError(
'Failed to get stats from API',
@@ -627,9 +638,9 @@ export class ApiStorage implements IStorage {
const options: RequestInit = {
method,
headers: {
'Authorization': `Bearer ${this.config.accessToken}`,
Authorization: `Bearer ${this.config.accessToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
Accept: 'application/json'
},
signal: controller.signal
};
@@ -654,16 +665,16 @@ export class ApiStorage implements IStorage {
// Handle specific error codes
if (response.status === 401) {
return {
success: false,
error: 'Authentication failed - check access token'
return {
success: false,
error: 'Authentication failed - check access token'
};
}
if (response.status === 404) {
return {
success: false,
error: 'Resource not found'
return {
success: false,
error: 'Resource not found'
};
}
@@ -678,7 +689,10 @@ export class ApiStorage implements IStorage {
const errorData = data as any;
return {
success: false,
error: errorData.error || errorData.message || `HTTP ${response.status}: ${response.statusText}`
error:
errorData.error ||
errorData.message ||
`HTTP ${response.status}: ${response.statusText}`
};
} catch (error) {
lastError = error as Error;
@@ -705,6 +719,6 @@ export class ApiStorage implements IStorage {
* Delay helper for retries
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
}

View File

@@ -5,7 +5,10 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import type { Task, TaskMetadata } from '../types/index.js';
import type { IStorage, StorageStats } from '../interfaces/storage.interface.js';
import type {
IStorage,
StorageStats
} from '../interfaces/storage.interface.js';
/**
* File storage data structure
@@ -80,7 +83,7 @@ export class FileStorage implements IStorage {
totalTags: tags.length,
lastModified: lastModified || new Date().toISOString(),
storageSize: 0, // Could calculate actual file sizes if needed
tagStats: tags.map(tag => ({
tagStats: tags.map((tag) => ({
tag,
taskCount: 0, // Would need to load each tag to get accurate count
lastModified: lastModified || new Date().toISOString()
@@ -218,14 +221,18 @@ export class FileStorage implements IStorage {
/**
* Update a specific task
*/
async updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void> {
async updateTask(
taskId: string,
updates: Partial<Task>,
tag?: string
): Promise<void> {
const tasks = await this.loadTasks(tag);
const taskIndex = tasks.findIndex(t => t.id === taskId);
const taskIndex = tasks.findIndex((t) => t.id === taskId);
if (taskIndex === -1) {
throw new Error(`Task ${taskId} not found`);
}
tasks[taskIndex] = { ...tasks[taskIndex], ...updates, id: taskId };
await this.saveTasks(tasks, tag);
}
@@ -235,12 +242,12 @@ export class FileStorage implements IStorage {
*/
async deleteTask(taskId: string, tag?: string): Promise<void> {
const tasks = await this.loadTasks(tag);
const filteredTasks = tasks.filter(t => t.id !== taskId);
const filteredTasks = tasks.filter((t) => t.id !== taskId);
if (filteredTasks.length === tasks.length) {
throw new Error(`Task ${taskId} not found`);
}
await this.saveTasks(filteredTasks, tag);
}
@@ -264,11 +271,13 @@ export class FileStorage implements IStorage {
async renameTag(oldTag: string, newTag: string): Promise<void> {
const oldPath = this.getTasksPath(oldTag);
const newPath = this.getTasksPath(newTag);
try {
await fs.rename(oldPath, newPath);
} catch (error: any) {
throw new Error(`Failed to rename tag from ${oldTag} to ${newTag}: ${error.message}`);
throw new Error(
`Failed to rename tag from ${oldTag} to ${newTag}: ${error.message}`
);
}
}
@@ -278,7 +287,7 @@ export class FileStorage implements IStorage {
async copyTag(sourceTag: string, targetTag: string): Promise<void> {
const tasks = await this.loadTasks(sourceTag);
const metadata = await this.loadMetadata(sourceTag);
await this.saveTasks(tasks, targetTag);
if (metadata) {
await this.saveMetadata(metadata, targetTag);
@@ -323,7 +332,9 @@ export class FileStorage implements IStorage {
/**
* Read and parse JSON file with error handling
*/
private async readJsonFile(filePath: string): Promise<FileStorageData | null> {
private async readJsonFile(
filePath: string
): Promise<FileStorageData | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
@@ -341,7 +352,10 @@ export class FileStorage implements IStorage {
/**
* Write JSON file with atomic operation using temp file
*/
private async writeJsonFile(filePath: string, data: FileStorageData): Promise<void> {
private async writeJsonFile(
filePath: string,
data: FileStorageData
): Promise<void> {
// Use file locking to prevent concurrent writes
const lockKey = filePath;
const existingLock = this.fileLocks.get(lockKey);
@@ -363,7 +377,10 @@ export class FileStorage implements IStorage {
/**
* Perform the actual write operation
*/
private async performWrite(filePath: string, data: FileStorageData): Promise<void> {
private async performWrite(
filePath: string,
data: FileStorageData
): Promise<void> {
const tempPath = `${filePath}.tmp`;
try {
@@ -406,11 +423,11 @@ export class FileStorage implements IStorage {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = this.getBackupPath(filePath, timestamp);
// Ensure backup directory exists
const backupDir = path.dirname(backupPath);
await fs.mkdir(backupDir, { recursive: true });
await fs.copyFile(filePath, backupPath);
// Clean up old backups if needed
@@ -432,7 +449,9 @@ export class FileStorage implements IStorage {
try {
const files = await fs.readdir(dir);
const backupFiles = files
.filter((f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json'))
.filter(
(f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json')
)
.sort()
.reverse();

View File

@@ -9,7 +9,10 @@ export { ApiStorage, type ApiStorageConfig } from './api-storage.js';
export { StorageFactory } from './storage-factory.js';
// Export storage interface and types
export type { IStorage, StorageStats } from '../interfaces/storage.interface.js';
export type {
IStorage,
StorageStats
} from '../interfaces/storage.interface.js';
// Placeholder exports - these will be implemented in later tasks
export interface StorageAdapter {

View File

@@ -2,169 +2,169 @@
* @fileoverview Storage factory for creating appropriate storage implementations
*/
import type { IStorage } from "../interfaces/storage.interface.js";
import type { IConfiguration } from "../interfaces/configuration.interface.js";
import { FileStorage } from "./file-storage.js";
import { ApiStorage } from "./api-storage.js";
import { ERROR_CODES, TaskMasterError } from "../errors/task-master-error.js";
import type { IStorage } from '../interfaces/storage.interface.js';
import type { IConfiguration } from '../interfaces/configuration.interface.js';
import { FileStorage } from './file-storage.js';
import { ApiStorage } from './api-storage.js';
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
/**
* Factory for creating storage implementations based on configuration
*/
export class StorageFactory {
/**
* Create a storage implementation based on configuration
* @param config - Configuration object
* @param projectPath - Project root path (for file storage)
* @returns Storage implementation
*/
static create(
config: Partial<IConfiguration>,
projectPath: string
): IStorage {
const storageType = config.storage?.type || "file";
/**
* Create a storage implementation based on configuration
* @param config - Configuration object
* @param projectPath - Project root path (for file storage)
* @returns Storage implementation
*/
static create(
config: Partial<IConfiguration>,
projectPath: string
): IStorage {
const storageType = config.storage?.type || 'file';
switch (storageType) {
case "file":
return StorageFactory.createFileStorage(projectPath, config);
switch (storageType) {
case 'file':
return StorageFactory.createFileStorage(projectPath, config);
case "api":
return StorageFactory.createApiStorage(config);
case 'api':
return StorageFactory.createApiStorage(config);
default:
throw new TaskMasterError(
`Unknown storage type: ${storageType}`,
ERROR_CODES.INVALID_INPUT,
{ storageType }
);
}
}
default:
throw new TaskMasterError(
`Unknown storage type: ${storageType}`,
ERROR_CODES.INVALID_INPUT,
{ storageType }
);
}
}
/**
* Create file storage implementation
*/
private static createFileStorage(
projectPath: string,
config: Partial<IConfiguration>
): FileStorage {
const basePath = config.storage?.basePath || projectPath;
return new FileStorage(basePath);
}
/**
* Create file storage implementation
*/
private static createFileStorage(
projectPath: string,
config: Partial<IConfiguration>
): FileStorage {
const basePath = config.storage?.basePath || projectPath;
return new FileStorage(basePath);
}
/**
* Create API storage implementation
*/
private static createApiStorage(config: Partial<IConfiguration>): ApiStorage {
const { apiEndpoint, apiAccessToken } = config.storage || {};
/**
* Create API storage implementation
*/
private static createApiStorage(config: Partial<IConfiguration>): ApiStorage {
const { apiEndpoint, apiAccessToken } = config.storage || {};
if (!apiEndpoint) {
throw new TaskMasterError(
"API endpoint is required for API storage",
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: "api" }
);
}
if (!apiEndpoint) {
throw new TaskMasterError(
'API endpoint is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
if (!apiAccessToken) {
throw new TaskMasterError(
"API access token is required for API storage",
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: "api" }
);
}
if (!apiAccessToken) {
throw new TaskMasterError(
'API access token is required for API storage',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
return new ApiStorage({
endpoint: apiEndpoint,
accessToken: apiAccessToken,
projectId: config.projectPath,
timeout: config.retry?.requestTimeout,
enableRetry: config.retry?.retryOnNetworkError,
maxRetries: config.retry?.retryAttempts,
});
}
return new ApiStorage({
endpoint: apiEndpoint,
accessToken: apiAccessToken,
projectId: config.projectPath,
timeout: config.retry?.requestTimeout,
enableRetry: config.retry?.retryOnNetworkError,
maxRetries: config.retry?.retryAttempts
});
}
/**
* Detect optimal storage type based on available configuration
*/
static detectOptimalStorage(config: Partial<IConfiguration>): "file" | "api" {
// If API credentials are provided, prefer API storage (Hamster)
if (config.storage?.apiEndpoint && config.storage?.apiAccessToken) {
return "api";
}
/**
* Detect optimal storage type based on available configuration
*/
static detectOptimalStorage(config: Partial<IConfiguration>): 'file' | 'api' {
// If API credentials are provided, prefer API storage (Hamster)
if (config.storage?.apiEndpoint && config.storage?.apiAccessToken) {
return 'api';
}
// Default to file storage
return "file";
}
// Default to file storage
return 'file';
}
/**
* Validate storage configuration
*/
static validateStorageConfig(config: Partial<IConfiguration>): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
const storageType = config.storage?.type;
/**
* Validate storage configuration
*/
static validateStorageConfig(config: Partial<IConfiguration>): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
const storageType = config.storage?.type;
if (!storageType) {
errors.push("Storage type is not specified");
return { isValid: false, errors };
}
if (!storageType) {
errors.push('Storage type is not specified');
return { isValid: false, errors };
}
switch (storageType) {
case "api":
if (!config.storage?.apiEndpoint) {
errors.push("API endpoint is required for API storage");
}
if (!config.storage?.apiAccessToken) {
errors.push("API access token is required for API storage");
}
break;
switch (storageType) {
case 'api':
if (!config.storage?.apiEndpoint) {
errors.push('API endpoint is required for API storage');
}
if (!config.storage?.apiAccessToken) {
errors.push('API access token is required for API storage');
}
break;
case "file":
// File storage doesn't require additional config
break;
case 'file':
// File storage doesn't require additional config
break;
default:
errors.push(`Unknown storage type: ${storageType}`);
}
default:
errors.push(`Unknown storage type: ${storageType}`);
}
return {
isValid: errors.length === 0,
errors,
};
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Check if Hamster (API storage) is available
*/
static isHamsterAvailable(config: Partial<IConfiguration>): boolean {
return !!(config.storage?.apiEndpoint && config.storage?.apiAccessToken);
}
/**
* Check if Hamster (API storage) is available
*/
static isHamsterAvailable(config: Partial<IConfiguration>): boolean {
return !!(config.storage?.apiEndpoint && config.storage?.apiAccessToken);
}
/**
* Create a storage implementation with fallback
* Tries API storage first, falls back to file storage
*/
static async createWithFallback(
config: Partial<IConfiguration>,
projectPath: string
): Promise<IStorage> {
// Try API storage if configured
if (StorageFactory.isHamsterAvailable(config)) {
try {
const apiStorage = StorageFactory.createApiStorage(config);
await apiStorage.initialize();
return apiStorage;
} catch (error) {
console.warn(
"Failed to initialize API storage, falling back to file storage:",
error
);
}
}
/**
* Create a storage implementation with fallback
* Tries API storage first, falls back to file storage
*/
static async createWithFallback(
config: Partial<IConfiguration>,
projectPath: string
): Promise<IStorage> {
// Try API storage if configured
if (StorageFactory.isHamsterAvailable(config)) {
try {
const apiStorage = StorageFactory.createApiStorage(config);
await apiStorage.initialize();
return apiStorage;
} catch (error) {
console.warn(
'Failed to initialize API storage, falling back to file storage:',
error
);
}
}
// Fallback to file storage
return StorageFactory.createFileStorage(projectPath, config);
}
// Fallback to file storage
return StorageFactory.createFileStorage(projectPath, config);
}
}

View File

@@ -3,7 +3,11 @@
*/
import { ConfigManager } from './config/config-manager.js';
import { TaskService, type TaskListResult as ListTasksResult, type GetTaskListOptions } from './services/task-service.js';
import {
TaskService,
type TaskListResult as ListTasksResult,
type GetTaskListOptions
} from './services/task-service.js';
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
import type { IConfiguration } from './interfaces/configuration.interface.js';
import type { Task, TaskStatus, TaskFilter } from './types/index.js';
@@ -33,7 +37,10 @@ export class TaskMasterCore {
constructor(options: TaskMasterCoreOptions) {
if (!options.projectPath) {
throw new TaskMasterError('Project path is required', ERROR_CODES.MISSING_CONFIGURATION);
throw new TaskMasterError(
'Project path is required',
ERROR_CODES.MISSING_CONFIGURATION
);
}
// Create config manager
@@ -108,7 +115,10 @@ export class TaskMasterCore {
/**
* Get tasks by status
*/
async getTasksByStatus(status: TaskStatus | TaskStatus[], tag?: string): Promise<Task[]> {
async getTasksByStatus(
status: TaskStatus | TaskStatus[],
tag?: string
): Promise<Task[]> {
await this.ensureInitialized();
return this.taskService.getTasksByStatus(status, tag);
}

View File

@@ -103,7 +103,10 @@ export interface TaskCollection {
/**
* Type for creating a new task (without generated fields)
*/
export type CreateTask = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'subtasks'> & {
export type CreateTask = Omit<
Task,
'id' | 'createdAt' | 'updatedAt' | 'subtasks'
> & {
subtasks?: Omit<Subtask, 'id' | 'parentId' | 'createdAt' | 'updatedAt'>[];
};
@@ -145,7 +148,15 @@ export interface TaskSortOptions {
export function isTaskStatus(value: unknown): value is TaskStatus {
return (
typeof value === 'string' &&
['pending', 'in-progress', 'done', 'deferred', 'cancelled', 'blocked', 'review'].includes(value)
[
'pending',
'in-progress',
'done',
'deferred',
'cancelled',
'blocked',
'review'
].includes(value)
);
}
@@ -153,7 +164,10 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
* Type guard to check if a value is a valid TaskPriority
*/
export function isTaskPriority(value: unknown): value is TaskPriority {
return typeof value === 'string' && ['low', 'medium', 'high', 'critical'].includes(value);
return (
typeof value === 'string' &&
['low', 'medium', 'high', 'critical'].includes(value)
);
}
/**
@@ -161,7 +175,8 @@ export function isTaskPriority(value: unknown): value is TaskPriority {
*/
export function isTaskComplexity(value: unknown): value is TaskComplexity {
return (
typeof value === 'string' && ['simple', 'moderate', 'complex', 'very-complex'].includes(value)
typeof value === 'string' &&
['simple', 'moderate', 'complex', 'very-complex'].includes(value)
);
}

View File

@@ -33,9 +33,14 @@ export function generateTaskId(): string {
* // Returns: "TASK-123-A7B3.2"
* ```
*/
export function generateSubtaskId(parentId: string, existingSubtasks: string[] = []): string {
export function generateSubtaskId(
parentId: string,
existingSubtasks: string[] = []
): string {
// Find existing subtasks for this parent
const parentSubtasks = existingSubtasks.filter((id) => id.startsWith(`${parentId}.`));
const parentSubtasks = existingSubtasks.filter((id) =>
id.startsWith(`${parentId}.`)
);
// Extract sequential numbers and find the highest
const sequentialNumbers = parentSubtasks
@@ -48,7 +53,8 @@ export function generateSubtaskId(parentId: string, existingSubtasks: string[] =
.sort((a, b) => a - b);
// Determine the next sequential number
const nextSequential = sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
const nextSequential =
sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
return `${parentId}.${nextSequential}`;
}

View File

@@ -323,7 +323,9 @@ describe('TaskMasterCore - listTasks E2E', () => {
it('should validate task entities', async () => {
// Write invalid task data
const invalidDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-invalid-'));
const invalidDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'tm-invalid-')
);
const tasksDir = path.join(invalidDir, '.taskmaster', 'tasks');
await fs.mkdir(tasksDir, { recursive: true });
@@ -349,7 +351,10 @@ describe('TaskMasterCore - listTasks E2E', () => {
}
};
await fs.writeFile(path.join(tasksDir, 'tasks.json'), JSON.stringify(invalidData));
await fs.writeFile(
path.join(tasksDir, 'tasks.json'),
JSON.stringify(invalidData)
);
const invalidCore = createTaskMasterCore(invalidDir);
@@ -379,7 +384,12 @@ describe('TaskMasterCore - listTasks E2E', () => {
}
];
const tagFile = path.join(tmpDir, '.taskmaster', 'tasks', 'feature-branch.json');
const tagFile = path.join(
tmpDir,
'.taskmaster',
'tasks',
'feature-branch.json'
);
await fs.writeFile(
tagFile,
JSON.stringify({

View File

@@ -58,7 +58,10 @@ export class MockProvider extends BaseProvider {
throw new Error('Mock provider error');
}
if (this.options.failAfterAttempts && this.attemptCount <= this.options.failAfterAttempts) {
if (
this.options.failAfterAttempts &&
this.attemptCount <= this.options.failAfterAttempts
) {
if (this.options.simulateRateLimit) {
throw new Error('Rate limit exceeded - too many requests (429)');
}
@@ -200,6 +203,8 @@ export class MockProvider extends BaseProvider {
// Override retry configuration for testing
protected getMaxRetries(): number {
return this.options.failAfterAttempts ? this.options.failAfterAttempts + 1 : 3;
return this.options.failAfterAttempts
? this.options.failAfterAttempts + 1
: 3;
}
}

View File

@@ -3,7 +3,10 @@
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { ERROR_CODES, TaskMasterError } from '../../src/errors/task-master-error';
import {
ERROR_CODES,
TaskMasterError
} from '../../src/errors/task-master-error';
import { MockProvider } from '../mocks/mock-provider';
describe('BaseProvider', () => {
@@ -64,21 +67,21 @@ describe('BaseProvider', () => {
});
it('should validate temperature range', async () => {
await expect(provider.generateCompletion('Test', { temperature: 3 })).rejects.toThrow(
'Temperature must be between 0 and 2'
);
await expect(
provider.generateCompletion('Test', { temperature: 3 })
).rejects.toThrow('Temperature must be between 0 and 2');
});
it('should validate maxTokens range', async () => {
await expect(provider.generateCompletion('Test', { maxTokens: 0 })).rejects.toThrow(
'Max tokens must be between 1 and 100000'
);
await expect(
provider.generateCompletion('Test', { maxTokens: 0 })
).rejects.toThrow('Max tokens must be between 1 and 100000');
});
it('should validate topP range', async () => {
await expect(provider.generateCompletion('Test', { topP: 1.5 })).rejects.toThrow(
'Top-p must be between 0 and 1'
);
await expect(
provider.generateCompletion('Test', { topP: 1.5 })
).rejects.toThrow('Top-p must be between 0 and 1');
});
});
@@ -125,7 +128,9 @@ describe('BaseProvider', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
// Access protected method through type assertion
const calculateDelay = (provider as any).calculateBackoffDelay.bind(provider);
const calculateDelay = (provider as any).calculateBackoffDelay.bind(
provider
);
const delay1 = calculateDelay(1);
const delay2 = calculateDelay(2);
@@ -164,7 +169,9 @@ describe('BaseProvider', () => {
it('should identify rate limit errors correctly', () => {
const provider = new MockProvider({ apiKey: 'test-key' });
const isRateLimitError = (provider as any).isRateLimitError.bind(provider);
const isRateLimitError = (provider as any).isRateLimitError.bind(
provider
);
expect(isRateLimitError(new Error('Rate limit exceeded'))).toBe(true);
expect(isRateLimitError(new Error('Too many requests'))).toBe(true);

View File

@@ -16,7 +16,12 @@ import {
version
} from '@tm/core';
import type { PlaceholderTask, TaskId, TaskPriority, TaskStatus } from '@tm/core';
import type {
PlaceholderTask,
TaskId,
TaskPriority,
TaskStatus
} from '@tm/core';
describe('tm-core smoke tests', () => {
describe('package metadata', () => {
@@ -45,7 +50,6 @@ describe('tm-core smoke tests', () => {
});
});
describe('placeholder storage', () => {
it('should perform basic storage operations', async () => {
const storage = new PlaceholderStorage();

View File

@@ -5,7 +5,12 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts'],
include: [
'tests/**/*.test.ts',
'tests/**/*.spec.ts',
'src/**/*.test.ts',
'src/**/*.spec.ts'
],
exclude: ['node_modules', 'dist', '.git', '.cache'],
coverage: {
provider: 'v8',