feat: add tm tags command to remote (#1386)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-11-12 20:08:27 +01:00
committed by GitHub
parent e59c16c707
commit 63134a222c
154 changed files with 6037 additions and 1391 deletions

View File

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

View File

@@ -0,0 +1,99 @@
import { ui } from '@tm/cli';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the add-tag bridge function
*/
export interface AddTagBridgeParams extends BaseBridgeParams {
/** Tag name to create */
tagName: string;
}
/**
* Result returned when API storage redirects to web UI
*/
export interface RemoteAddTagResult {
success: boolean;
message: string;
redirectUrl: string;
}
/**
* Shared bridge function for add-tag command.
* Checks if using API storage and redirects to web UI if so.
*
* For API storage, tags are called "briefs" and must be created
* through the Hamster web interface.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryAddTagViaRemote(
params: AddTagBridgeParams
): Promise<RemoteAddTagResult | null> {
const {
tagName,
projectRoot,
isMCP = false,
outputFormat = 'text',
report
} = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tag creation'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
// Get the brief creation URL from tmCore
const redirectUrl = tmCore.auth.getBriefCreationUrl();
if (!redirectUrl) {
report(
'error',
'Could not generate brief creation URL. Please ensure you have selected an organization using "tm context org"'
);
return {
success: false,
message:
'Failed to generate brief creation URL. Please ensure an organization is selected.',
redirectUrl: ''
};
}
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
console.log(
ui.displayCardBox({
header: '# Create a Brief in Hamster Studio',
body: [
'Your tags are separate task lists. When connected to Hamster,\ntask lists are attached to briefs.',
'Create a new brief and its task list will automatically be\navailable when generated.'
],
callToAction: {
label: 'Visit:',
action: redirectUrl
},
footer:
'To access tasks for a specific brief, use:\n' +
' • tm briefs select <brief-name>\n' +
' • tm briefs select <brief-id>\n' +
' • tm briefs select (interactive)'
})
);
}
// Return success result with redirect URL
return {
success: true,
message: `API storage detected. Please create tag "${tagName}" at: ${redirectUrl}`,
redirectUrl
};
}

View File

@@ -0,0 +1,46 @@
/**
* Shared types and interfaces for bridge functions
*/
/**
* Log levels used by bridge report functions
*/
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
/**
* Report function signature used by all bridges
*/
export type ReportFunction = (level: LogLevel, ...args: unknown[]) => void;
/**
* Output format for bridge results
*/
export type OutputFormat = 'text' | 'json';
/**
* Common parameters shared by all bridge functions
*/
export interface BaseBridgeParams {
/** Project root directory */
projectRoot: string;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: OutputFormat;
/** Logging function */
report: ReportFunction;
/** Optional tag for task organization */
tag?: string;
}
/**
* Result from checking if API storage should handle an operation
*/
export interface StorageCheckResult {
/** Whether API storage is being used */
isApiStorage: boolean;
/** TmCore instance if initialization succeeded */
tmCore?: import('@tm/core').TmCore;
/** Error message if initialization failed */
error?: string;
}

View File

@@ -0,0 +1,69 @@
/**
* Shared utility functions for bridge operations
*/
import { type TmCore, createTmCore } from '@tm/core';
import type { ReportFunction, StorageCheckResult } from './bridge-types.js';
/**
* Initialize TmCore and check if API storage is being used.
*
* This function encapsulates the common pattern used by all bridge functions:
* 1. Try to create TmCore instance
* 2. Check the storage type
* 3. Return results or handle errors gracefully
*
* @param projectRoot - Project root directory
* @param report - Logging function
* @param fallbackMessage - Message to log if TmCore initialization fails
* @returns Storage check result with TmCore instance if successful
*
* @example
* const { isApiStorage, tmCore } = await checkStorageType(
* projectRoot,
* report,
* 'falling back to file-based operation'
* );
*
* if (!isApiStorage) {
* // Continue with file-based logic
* return null;
* }
*/
export async function checkStorageType(
projectRoot: string,
report: ReportFunction,
fallbackMessage = 'falling back to file-based operation'
): Promise<StorageCheckResult> {
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, ${fallbackMessage}: ${errorMessage}`);
return {
isApiStorage: false,
error: errorMessage
};
}
// Check if we're using API storage (use resolved storage type, not config)
const storageType = tmCore.tasks.getStorageType();
if (storageType !== 'api') {
return {
isApiStorage: false,
tmCore
};
}
return {
isApiStorage: true,
tmCore
};
}

View File

@@ -1,12 +1,13 @@
import chalk from 'chalk';
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the expand bridge function
*/
export interface ExpandBridgeParams {
export interface ExpandBridgeParams extends BaseBridgeParams {
/** Task ID (can be numeric "1" or alphanumeric "TAS-49") */
taskId: string | number;
/** Number of subtasks to generate (optional) */
@@ -17,16 +18,6 @@ export interface ExpandBridgeParams {
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;
}
/**
@@ -63,32 +54,15 @@ export async function tryExpandViaRemote(
report
} = params;
let tmCore: TmCore;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based expansion'
);
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') {
if (!isApiStorage || !tmCore) {
// 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;
}

View File

@@ -9,6 +9,18 @@
* DELETE THIS PACKAGE when legacy scripts are removed.
*/
// Shared types and utilities
export type {
LogLevel,
ReportFunction,
OutputFormat,
BaseBridgeParams,
StorageCheckResult
} from './bridge-types.js';
export { checkStorageType } from './bridge-utils.js';
// Bridge functions
export {
tryUpdateViaRemote,
type UpdateBridgeParams,
@@ -20,3 +32,22 @@ export {
type ExpandBridgeParams,
type RemoteExpandResult
} from './expand-bridge.js';
export {
tryListTagsViaRemote,
type TagsBridgeParams,
type RemoteTagsResult,
type TagInfo
} from './tags-bridge.js';
export {
tryUseTagViaRemote,
type UseTagBridgeParams,
type RemoteUseTagResult
} from './use-tag-bridge.js';
export {
tryAddTagViaRemote,
type AddTagBridgeParams,
type RemoteAddTagResult
} from './add-tag-bridge.js';

View File

@@ -0,0 +1,160 @@
import { ui } from '@tm/cli';
import boxen from 'boxen';
import chalk from 'chalk';
import Table from 'cli-table3';
import type { TagInfo } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
// Re-export for convenience
export type { TagInfo };
/**
* Parameters for the tags bridge function
*/
export interface TagsBridgeParams extends BaseBridgeParams {
/** Whether to show metadata (default: false) */
showMetadata?: boolean;
}
/**
* Result returned when API storage handles the tags listing
*/
export interface RemoteTagsResult {
success: boolean;
tags: TagInfo[];
currentTag: string | null;
totalTags: number;
message: string;
}
/**
* Shared bridge function for list-tags command.
* Checks if using API storage and delegates to remote service if so.
*
* For API storage, tags are called "briefs" and task counts are fetched
* from the remote database.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryListTagsViaRemote(
params: TagsBridgeParams
): Promise<RemoteTagsResult | null> {
const { projectRoot, isMCP = false, outputFormat = 'text', report } = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tags'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
try {
// Get tags with statistics from tm-core
// Tags are already sorted by status and updatedAt from brief-service
const tagsResult = await tmCore.tasks.getTagsWithStats();
// Sort tags: current tag first, then preserve status/updatedAt ordering from service
tagsResult.tags.sort((a, b) => {
// Always keep current tag at the top
if (a.isCurrent) return -1;
if (b.isCurrent) return 1;
// For non-current tags, preserve the status/updatedAt ordering already applied
return 0;
});
if (outputFormat === 'text' && !isMCP) {
// Display results in a table format
if (tagsResult.tags.length === 0) {
console.log(
boxen(chalk.yellow('No tags found'), {
padding: 1,
borderColor: 'yellow',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
})
);
} else {
// Create table headers (with temporary Updated column)
const headers = [
chalk.cyan.bold('Tag Name'),
chalk.cyan.bold('Status'),
chalk.cyan.bold('Updated'),
chalk.cyan.bold('Tasks'),
chalk.cyan.bold('Completed')
];
// Calculate dynamic column widths based on terminal width
const terminalWidth = Math.max(
(process.stdout.columns as number) || 120,
80
);
const usableWidth = Math.floor(terminalWidth * 0.95);
// Column order: Tag Name, Status, Updated, Tasks, Completed
const widths = [0.35, 0.25, 0.2, 0.1, 0.1];
const colWidths = widths.map((w, i) =>
Math.max(Math.floor(usableWidth * w), i === 0 ? 20 : 8)
);
const table = new Table({
head: headers,
colWidths: colWidths,
wordWrap: true
});
// Add rows
tagsResult.tags.forEach((tag) => {
const row = [];
// Tag name with current indicator and short ID (last 8 chars)
const shortId = tag.briefId ? tag.briefId.slice(-8) : 'unknown';
const tagDisplay = tag.isCurrent
? `${chalk.green('●')} ${chalk.green.bold(tag.name)} ${chalk.gray(`(current - ${shortId})`)}`
: ` ${tag.name} ${chalk.gray(`(${shortId})`)}`;
row.push(tagDisplay);
row.push(ui.getBriefStatusWithColor(tag.status, true));
// Updated date (temporary for validation)
const updatedDate = tag.updatedAt
? new Date(tag.updatedAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: chalk.gray('N/A');
row.push(chalk.gray(updatedDate));
// Task counts
row.push(chalk.white(tag.taskCount.toString()));
row.push(chalk.green(tag.completedTasks.toString()));
table.push(row);
});
console.log(table.toString());
}
}
// Return success result - signals that we handled it
return {
success: true,
tags: tagsResult.tags,
currentTag: tagsResult.currentTag,
totalTags: tagsResult.totalTags,
message: `Found ${tagsResult.totalTags} tag(s)`
};
} catch (error) {
// tm-core already formatted the error properly, just re-throw
throw error;
}
}

View File

@@ -1,28 +1,19 @@
import chalk from 'chalk';
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import { createTmCore, type TmCore } from '@tm/core';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the update bridge function
*/
export interface UpdateBridgeParams {
export interface UpdateBridgeParams extends BaseBridgeParams {
/** Task ID (can be numeric "1", alphanumeric "TAS-49", or dotted "1.2" or "TAS-49.1") */
taskId: string | number;
/** Update prompt for AI */
prompt: string;
/** Project root directory */
projectRoot: string;
/** Optional tag for task organization */
tag?: string;
/** Whether to append or full update (default: false) */
appendMode?: boolean;
/** Whether called from MCP context (default: false) */
isMCP?: boolean;
/** Output format (default: 'text') */
outputFormat?: 'text' | 'json';
/** Logging function */
report: (level: string, ...args: unknown[]) => void;
}
/**
@@ -61,32 +52,15 @@ export async function tryUpdateViaRemote(
report
} = params;
let tmCore: TmCore;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based update'
);
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 update: ${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') {
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
report(
'info',
`Using file storage - processing update locally for task ${taskId}`
);
return null;
}

View File

@@ -0,0 +1,145 @@
import boxen from 'boxen';
import chalk from 'chalk';
import ora from 'ora';
import type { BaseBridgeParams } from './bridge-types.js';
import { checkStorageType } from './bridge-utils.js';
/**
* Parameters for the use-tag bridge function
*/
export interface UseTagBridgeParams extends BaseBridgeParams {
/** Tag name to switch to */
tagName: string;
}
/**
* Result returned when API storage handles the tag switch
*/
export interface RemoteUseTagResult {
success: boolean;
previousTag: string | null;
currentTag: string;
switched: boolean;
taskCount: number;
message: string;
}
/**
* Shared bridge function for use-tag command.
* Checks if using API storage and delegates to remote service if so.
*
* For API storage, tags are called "briefs" and switching tags means
* changing the current brief context.
*
* @param params - Bridge parameters
* @returns Result object if API storage handled it, null if should fall through to file storage
*/
export async function tryUseTagViaRemote(
params: UseTagBridgeParams
): Promise<RemoteUseTagResult | null> {
const {
tagName,
projectRoot,
isMCP = false,
outputFormat = 'text',
report
} = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
projectRoot,
report,
'falling back to file-based tag switching'
);
if (!isApiStorage || !tmCore) {
// Not API storage - signal caller to fall through to file-based logic
return null;
}
// API STORAGE PATH: Switch brief in Hamster
report('info', `Switching to tag (brief) "${tagName}" in Hamster`);
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
console.log(
boxen(chalk.blue.bold(`Switching Tag in Hamster`), {
padding: 1,
borderColor: 'blue',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
})
);
}
const spinner =
!isMCP && outputFormat === 'text'
? ora({ text: `Switching to tag "${tagName}"...`, color: 'cyan' }).start()
: null;
try {
// Get current context before switching
const previousContext = tmCore.auth.getContext();
const previousTag = previousContext?.briefName || null;
// Switch to the new tag/brief
// This will look up the brief by name and update the context
await tmCore.tasks.switchTag(tagName);
// Get updated context after switching
const newContext = tmCore.auth.getContext();
const currentTag = newContext?.briefName || tagName;
// Get task count for the new tag
const tasks = await tmCore.tasks.list();
const taskCount = tasks.tasks.length;
if (spinner) {
spinner.succeed(`Switched to tag "${currentTag}"`);
}
if (outputFormat === 'text' && !isMCP) {
// Display success message
const briefId = newContext?.briefId
? newContext.briefId.slice(-8)
: 'unknown';
console.log(
boxen(
chalk.green.bold('✓ Tag Switched Successfully') +
'\n\n' +
(previousTag
? chalk.white(`Previous Tag: ${chalk.cyan(previousTag)}\n`)
: '') +
chalk.white(`Current Tag: ${chalk.green.bold(currentTag)}`) +
'\n' +
chalk.gray(`Brief ID: ${briefId}`) +
'\n' +
chalk.white(`Available Tasks: ${chalk.yellow(taskCount)}`),
{
padding: 1,
borderColor: 'green',
borderStyle: 'round',
margin: { top: 1, bottom: 0 }
}
)
);
}
// Return success result - signals that we handled it
return {
success: true,
previousTag,
currentTag,
switched: true,
taskCount,
message: `Successfully switched to tag "${currentTag}"`
};
} catch (error) {
if (spinner) {
spinner.fail('Failed to switch tag');
}
// tm-core already formatted the error properly, just re-throw
throw error;
}
}

View File

@@ -4,11 +4,25 @@
*/
import type {
TaskStatus,
TaskComplexity,
TaskPriority,
TaskComplexity
TaskStatus
} from '../types/index.js';
// Import from root package.json (monorepo root) for version info
import packageJson from '../../../../../package.json' with { type: 'json' };
/**
* Task Master version from root package.json
* Centralized to avoid fragile relative paths throughout the codebase
*/
export const TASKMASTER_VERSION = packageJson.version || 'unknown';
/**
* Package name from root package.json
*/
export const PACKAGE_NAME = packageJson.name || 'task-master-ai';
/**
* Valid task status values
*/

View File

@@ -4,9 +4,9 @@
*/
import type {
StorageType,
TaskComplexity,
TaskPriority,
StorageType
TaskPriority
} from '../types/index.js';
/**

View File

@@ -3,8 +3,8 @@
* This file defines the contract for all storage implementations
*/
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
import type { ExpandTaskResult } from '../../modules/integration/services/task-expansion.service.js';
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
/**
* Options for loading tasks from storage
@@ -164,6 +164,19 @@ export interface IStorage {
*/
getAllTags(): Promise<string[]>;
/**
* Create a new tag
* @param tagName - Name of the tag to create
* @param options - Creation options
* @param options.copyFrom - Tag to copy tasks from (optional)
* @param options.description - Tag description (optional)
* @returns Promise that resolves when creation is complete
*/
createTag(
tagName: string,
options?: { copyFrom?: string; description?: string }
): Promise<void>;
/**
* Delete all tasks and metadata for a specific tag
* @param tag - Tag to delete
@@ -216,6 +229,57 @@ export interface IStorage {
* @returns The brief name if using API storage with a selected brief, null otherwise
*/
getCurrentBriefName(): string | null;
/**
* Get all tags with detailed statistics including task counts
* @returns Promise that resolves to tags with statistics
*/
getTagsWithStats(): Promise<TagsWithStatsResult>;
}
/**
* Tag information with detailed statistics
*/
export interface TagInfo {
/** Tag/Brief name */
name: string;
/** Whether this is the current/active tag */
isCurrent: boolean;
/** Total number of tasks in this tag */
taskCount: number;
/** Number of completed tasks */
completedTasks: number;
/** Breakdown of tasks by status */
statusBreakdown: Record<string, number>;
/** Subtask counts if available */
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
/** Tag creation date */
created?: string;
/** Tag last modified date */
updatedAt?: string;
/** Tag description */
description?: string;
/** Brief/Tag status (for API storage briefs) */
status?: string;
/** Brief ID/UUID (for API storage) */
briefId?: string;
}
/**
* Result returned by getTagsWithStats
*/
export interface TagsWithStatsResult {
/** List of tags with statistics */
tags: TagInfo[];
/** Current active tag name */
currentTag: string | null;
/** Total number of tags */
totalTags: number;
}
/**
@@ -303,6 +367,10 @@ export abstract class BaseStorage implements IStorage {
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;
abstract saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void>;
abstract getAllTags(): Promise<string[]>;
abstract createTag(
tagName: string,
options?: { copyFrom?: string; description?: string }
): Promise<void>;
abstract deleteTag(tag: string): Promise<void>;
abstract renameTag(oldTag: string, newTag: string): Promise<void>;
abstract copyTag(sourceTag: string, targetTag: string): Promise<void>;
@@ -311,6 +379,7 @@ export abstract class BaseStorage implements IStorage {
abstract getStats(): Promise<StorageStats>;
abstract getStorageType(): 'file' | 'api';
abstract getCurrentBriefName(): string | null;
abstract getTagsWithStats(): Promise<TagsWithStatsResult>;
/**
* Utility method to generate backup filename
* @param originalPath - Original file path

View File

@@ -2,8 +2,8 @@
* @fileoverview Tests for MCP logging integration
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Logger, LogLevel, type LogCallback } from './logger.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type LogCallback, LogLevel, Logger } from './logger.js';
describe('Logger - MCP Integration', () => {
// Store original environment

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { TaskMapper } from './TaskMapper.js';
import { describe, expect, it, vi } from 'vitest';
import type { Tables } from '../types/database.types.js';
import { TaskMapper } from './TaskMapper.js';
type TaskRow = Tables<'tasks'>;

View File

@@ -1,5 +1,5 @@
import { Task, Subtask } from '../types/index.js';
import { Database, Tables } from '../types/database.types.js';
import { Subtask, Task } from '../types/index.js';
type TaskRow = Tables<'tasks'>;

View File

@@ -105,6 +105,8 @@ export interface TaskMetadata {
projectName?: string;
description?: string;
tags?: string[];
created?: string;
updated?: string;
}
/**

View File

@@ -53,6 +53,9 @@ export {
normalizeProjectRoot
} from './project-root-finder.js';
// Export path construction utilities
export { getProjectPaths } from './path-helpers.js';
// Additional utility exports
/**

View File

@@ -0,0 +1,40 @@
/**
* @fileoverview Path construction utilities for Task Master
* Provides standardized paths for Task Master project structure
*/
import { join, resolve } from 'node:path';
import { TASKMASTER_TASKS_FILE } from '../constants/paths.js';
import { findProjectRoot } from './project-root-finder.js';
/**
* Get standard Task Master project paths
* Automatically detects project root using smart detection
*
* @param projectPath - Optional explicit project path (if not provided, auto-detects)
* @returns Object with projectRoot and tasksPath
*
* @example
* ```typescript
* // Auto-detect project root
* const { projectRoot, tasksPath } = getProjectPaths();
*
* // Or specify explicit path
* const { projectRoot, tasksPath } = getProjectPaths('./my-project');
* // projectRoot: '/absolute/path/to/my-project'
* // tasksPath: '/absolute/path/to/my-project/.taskmaster/tasks/tasks.json'
* ```
*/
export function getProjectPaths(projectPath?: string): {
projectRoot: string;
tasksPath: string;
} {
const projectRoot = projectPath
? resolve(process.cwd(), projectPath)
: findProjectRoot();
return {
projectRoot,
tasksPath: join(projectRoot, TASKMASTER_TASKS_FILE)
};
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
normalizeProjectPath,
denormalizeProjectPath,
isValidNormalizedPath
isValidNormalizedPath,
normalizeProjectPath
} from './path-normalizer.js';
describe('Path Normalizer (base64url encoding)', () => {

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
compareRunIds,
generateRunId,
isValidRunId,
parseRunId,
compareRunIds
parseRunId
} from './run-id-generator.js';
describe('Run ID Generator', () => {

View File

@@ -36,6 +36,12 @@ export type * from './common/types/index.js';
// Common interfaces
export type * from './common/interfaces/index.js';
// Storage interfaces - TagInfo and TagsWithStatsResult
export type {
TagInfo,
TagsWithStatsResult
} from './common/interfaces/storage.interface.js';
// Constants
export * from './common/constants/index.js';
@@ -75,6 +81,10 @@ export type {
} from './modules/auth/types.js';
export { AuthenticationError } from './modules/auth/types.js';
// Brief types
export type { Brief } from './modules/briefs/types.js';
export type { TagWithStats } from './modules/briefs/services/brief-service.js';
// Workflow types
export type {
StartWorkflowOptions,
@@ -112,6 +122,10 @@ export type {
// Auth - Advanced
export { AuthManager } from './modules/auth/managers/auth-manager.js';
// Briefs - Advanced
export { BriefsDomain } from './modules/briefs/briefs-domain.js';
export { BriefService } from './modules/briefs/services/brief-service.js';
// Workflow - Advanced
export { WorkflowOrchestrator } from './modules/workflow/orchestrators/workflow-orchestrator.js';
export { WorkflowStateManager } from './modules/workflow/managers/workflow-state-manager.js';

View File

@@ -8,12 +8,12 @@ import {
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type {
AIModel,
AIOptions,
AIResponse,
IAIProvider,
ProviderUsageStats,
ProviderInfo,
AIModel
ProviderUsageStats
} from '../interfaces/ai-provider.interface.js';
// Constants for retry logic

View File

@@ -0,0 +1,111 @@
/**
* Auth Domain tests
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { AuthDomain } from './auth-domain.js';
describe('AuthDomain', () => {
let authDomain: AuthDomain;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save original environment
originalEnv = { ...process.env };
authDomain = new AuthDomain();
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
vi.clearAllMocks();
});
describe('getBriefCreationUrl', () => {
it('should return null if no base domain is configured', () => {
// Clear environment variables
delete process.env.TM_BASE_DOMAIN;
delete process.env.TM_PUBLIC_BASE_DOMAIN;
// Create fresh instance with cleared env
const domain = new AuthDomain();
const url = domain.getBriefCreationUrl();
expect(url).toBeNull();
});
it('should return null if org slug is not available in context', () => {
// Set base domain but context will have no orgSlug
process.env.TM_BASE_DOMAIN = 'localhost:8080';
const domain = new AuthDomain();
// Mock getContext to return null (no context set)
vi.spyOn(domain, 'getContext').mockReturnValue(null);
const url = domain.getBriefCreationUrl();
expect(url).toBeNull();
});
it('should construct URL with http protocol for localhost', () => {
process.env.TM_BASE_DOMAIN = 'localhost:8080';
// Mock getContext to return a context with orgSlug
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'test-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe('http://localhost:8080/home/test-org/briefs/create');
});
it('should construct URL with https protocol for production domain', () => {
process.env.TM_BASE_DOMAIN = 'tryhamster.com';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'acme-corp',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe('https://tryhamster.com/home/acme-corp/briefs/create');
});
it('should use existing protocol if base domain includes it', () => {
process.env.TM_BASE_DOMAIN = 'https://staging.hamster.dev';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'staging-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
expect(url).toBe(
'https://staging.hamster.dev/home/staging-org/briefs/create'
);
});
it('should prefer TM_BASE_DOMAIN over TM_PUBLIC_BASE_DOMAIN', () => {
process.env.TM_BASE_DOMAIN = 'localhost:8080';
process.env.TM_PUBLIC_BASE_DOMAIN = 'tryhamster.com';
const domain = new AuthDomain();
vi.spyOn(domain, 'getContext').mockReturnValue({
orgSlug: 'my-org',
updatedAt: new Date().toISOString()
});
const url = domain.getBriefCreationUrl();
// Should use TM_BASE_DOMAIN (localhost), not TM_PUBLIC_BASE_DOMAIN
expect(url).toBe('http://localhost:8080/home/my-org/briefs/create');
});
});
});

View File

@@ -4,18 +4,18 @@
*/
import path from 'node:path';
import type { StorageType } from '../../common/types/index.js';
import type { Brief } from '../briefs/types.js';
import { AuthManager } from './managers/auth-manager.js';
import type {
Organization,
RemoteTask
} from './services/organization.service.js';
import type {
AuthCredentials,
OAuthFlowOptions,
UserContext
} from './types.js';
import type {
Organization,
Brief,
RemoteTask
} from './services/organization.service.js';
import type { StorageType } from '../../common/types/index.js';
/**
* Display information for storage context
@@ -210,6 +210,21 @@ export class AuthDomain {
};
}
/**
* Get the URL for creating a new brief in the web UI
* Returns null if not using API storage or if org slug is not available
*/
getBriefCreationUrl(): string | null {
const context = this.getContext();
const baseUrl = this.getWebAppUrl();
if (!baseUrl || !context?.orgSlug) {
return null;
}
return `${baseUrl}/home/${context.orgSlug}/briefs/create`;
}
/**
* Get web app base URL from environment configuration
* @private

View File

@@ -6,34 +6,54 @@ import os from 'os';
import path from 'path';
import { AuthConfig } from './types.js';
// Single base domain for all URLs
// Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
// Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
// Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
// Default: https://tryhamster.com for production
const BASE_DOMAIN =
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
process.env.TM_PUBLIC_BASE_DOMAIN; // Build-time (baked into compiled code)
/**
* Get the base domain from environment variables
* Evaluated lazily to allow dotenv to load first
* Runtime vars (TM_*) take precedence over build-time vars (TM_PUBLIC_*)
* Build-time: process.env.TM_PUBLIC_BASE_DOMAIN gets replaced by tsdown's env option
* Runtime: process.env.TM_BASE_DOMAIN can override for staging/development
* Default: https://tryhamster.com for production
*/
function getBaseDomain(): string {
return (
process.env.TM_BASE_DOMAIN || // Runtime override (for staging/tux)
process.env.TM_PUBLIC_BASE_DOMAIN || // Build-time (baked into compiled code)
'https://tryhamster.com' // Fallback default
);
}
/**
* Default authentication configuration
* Get default authentication configuration
* All URL configuration is derived from the single BASE_DOMAIN
* Evaluated lazily to allow dotenv to load environment variables first
*/
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
// Base domain for all services
baseUrl: BASE_DOMAIN!,
function getDefaultAuthConfig(): AuthConfig {
return {
// Base domain for all services
baseUrl: getBaseDomain(),
// Configuration directory and file paths
configDir: path.join(os.homedir(), '.taskmaster'),
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
};
// Configuration directory and file paths
configDir: path.join(os.homedir(), '.taskmaster'),
configFile: path.join(os.homedir(), '.taskmaster', 'auth.json')
};
}
/**
* Get merged configuration with optional overrides
*/
export function getAuthConfig(overrides?: Partial<AuthConfig>): AuthConfig {
return {
...DEFAULT_AUTH_CONFIG,
...getDefaultAuthConfig(),
...overrides
};
}
/**
* Default authentication configuration (exported for backward compatibility)
* Note: This is now a getter property that evaluates lazily
*/
export const DEFAULT_AUTH_CONFIG: AuthConfig = new Proxy({} as AuthConfig, {
get(_target, prop) {
return getDefaultAuthConfig()[prop as keyof AuthConfig];
}
});

View File

@@ -9,7 +9,6 @@ export { OAuthService } from './services/oauth-service.js';
export { SupabaseSessionStorage } from './services/supabase-session-storage.js';
export type {
Organization,
Brief,
RemoteTask
} from './services/organization.service.js';

View File

@@ -2,7 +2,7 @@
* Tests for AuthManager singleton behavior
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the logger to verify warnings (must be hoisted before SUT import)
const mockLogger = {

View File

@@ -2,31 +2,31 @@
* Authentication manager for Task Master CLI
*/
import {
AuthCredentials,
OAuthFlowOptions,
AuthenticationError,
AuthConfig,
UserContext,
UserContextWithBrief
} from '../types.js';
import { ContextStore } from '../services/context-store.js';
import { OAuthService } from '../services/oauth-service.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import {
OrganizationService,
type Organization,
type Brief,
type RemoteTask
} from '../services/organization.service.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/index.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { Brief } from '../../briefs/types.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { ContextStore } from '../services/context-store.js';
import { OAuthService } from '../services/oauth-service.js';
import {
type Organization,
OrganizationService,
type RemoteTask
} from '../services/organization.service.js';
import {
AuthConfig,
AuthCredentials,
AuthenticationError,
OAuthFlowOptions,
UserContext,
UserContextWithBrief
} from '../types.js';
/**
* Authentication manager class

View File

@@ -11,8 +11,8 @@
import fs from 'fs';
import path from 'path';
import { UserContext, AuthenticationError } from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
import { AuthenticationError, UserContext } from '../types.js';
const DEFAULT_CONTEXT_FILE = path.join(
process.env.HOME || process.env.USERPROFILE || '~',

View File

@@ -2,23 +2,23 @@
* OAuth 2.0 Authorization Code Flow service
*/
import http from 'http';
import { URL } from 'url';
import crypto from 'crypto';
import http from 'http';
import os from 'os';
import {
AuthCredentials,
AuthenticationError,
OAuthFlowOptions,
AuthConfig,
CliData
} from '../types.js';
import { ContextStore } from '../services/context-store.js';
import { URL } from 'url';
import { Session } from '@supabase/supabase-js';
import { TASKMASTER_VERSION } from '../../../common/constants/index.js';
import { getLogger } from '../../../common/logger/index.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { getAuthConfig } from '../config.js';
import { getLogger } from '../../../common/logger/index.js';
import packageJson from '../../../../../../package.json' with { type: 'json' };
import { Session } from '@supabase/supabase-js';
import { ContextStore } from '../services/context-store.js';
import {
AuthConfig,
AuthCredentials,
AuthenticationError,
CliData,
OAuthFlowOptions
} from '../types.js';
export class OAuthService {
private logger = getLogger('OAuthService');
@@ -417,10 +417,10 @@ export class OAuthService {
}
/**
* Get CLI version from package.json if available
* Get CLI version from centralized constants
*/
private getCliVersion(): string {
return packageJson.version || 'unknown';
return TASKMASTER_VERSION;
}
/**

View File

@@ -4,12 +4,13 @@
*/
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '../../../common/types/database.types.js';
import {
TaskMasterError,
ERROR_CODES
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { getLogger } from '../../../common/logger/index.js';
import { Database } from '../../../common/types/database.types.js';
import type { Brief } from '../../briefs/types.js';
/**
* Organization data structure
@@ -20,24 +21,6 @@ export interface Organization {
slug: string;
}
/**
* Brief data structure
*/
export interface Brief {
id: string;
accountId: string;
documentId: string;
status: string;
createdAt: string;
updatedAt: string;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
}
/**
* Task data structure from the remote database
*/
@@ -181,6 +164,7 @@ export class OrganizationService {
status,
created_at,
updated_at,
tasks(count),
document:document_id (
id,
document_name,
@@ -211,6 +195,9 @@ export class OrganizationService {
status: brief.status,
createdAt: brief.created_at,
updatedAt: brief.updated_at,
taskCount: Array.isArray(brief.tasks)
? (brief.tasks[0]?.count ?? 0)
: 0,
document: brief.document
? {
id: brief.document.id,

View File

@@ -14,9 +14,9 @@
* - Persistence to ~/.taskmaster/session.json
*/
import type { SupportedStorage } from '@supabase/supabase-js';
import fs from 'fs';
import path from 'path';
import type { SupportedStorage } from '@supabase/supabase-js';
import { getLogger } from '../../../common/logger/index.js';
const DEFAULT_SESSION_FILE = path.join(

View File

@@ -19,6 +19,8 @@ export interface UserContext {
orgSlug?: string;
briefId?: string;
briefName?: string;
briefStatus?: string;
briefUpdatedAt?: string;
updatedAt: string;
}

View File

@@ -0,0 +1,182 @@
/**
* @fileoverview Briefs Domain Facade
* Public API for brief-related operations
*/
import {
ERROR_CODES,
TaskMasterError
} from '../../common/errors/task-master-error.js';
import { AuthManager } from '../auth/managers/auth-manager.js';
import type { TaskRepository } from '../tasks/repositories/task-repository.interface.js';
import { BriefService, type TagWithStats } from './services/brief-service.js';
/**
* Briefs Domain - Unified API for brief operations
* Handles brief switching, matching, and statistics
*/
export class BriefsDomain {
private briefService: BriefService;
private authManager: AuthManager;
constructor() {
this.briefService = new BriefService();
this.authManager = AuthManager.getInstance();
}
/**
* Resolve a brief by name, ID, URL, or partial ID without updating context
* Returns the full brief object
*
* Supports:
* - Hamster URLs (e.g., https://app.tryhamster.com/home/hamster/briefs/abc123)
* - Full UUID
* - Last 8 characters of UUID
* - Brief name (exact or partial match)
*
* @param input - Raw input: URL, UUID, last 8 chars, or brief name
* @param orgId - Optional organization ID. If not provided, uses current context.
* @returns The resolved brief object
*/
async resolveBrief(input: string, orgId?: string): Promise<any> {
// Extract brief ID/name from input (could be URL, ID, or name)
const briefIdOrName = this.extractBriefIdentifier(input);
// Get org ID from parameter or context
const resolvedOrgId = orgId || this.authManager.getContext()?.orgId;
if (!resolvedOrgId) {
throw new TaskMasterError(
'No organization selected. Run "tm context org" first.',
ERROR_CODES.CONFIG_ERROR
);
}
// Fetch all briefs for the org
const briefs = await this.authManager.getBriefs(resolvedOrgId);
// Find matching brief using service
const matchingBrief = await this.briefService.findBrief(
briefs,
briefIdOrName
);
this.briefService.validateBriefFound(matchingBrief, briefIdOrName);
return matchingBrief;
}
/**
* Extract brief identifier from raw input
* Handles URLs, paths, and direct IDs
*
* @param input - Raw input string
* @returns Extracted brief identifier
*/
private extractBriefIdentifier(input: string): string {
const raw = input?.trim() ?? '';
if (!raw) {
throw new TaskMasterError(
'Brief identifier cannot be empty',
ERROR_CODES.VALIDATION_ERROR
);
}
const parseUrl = (s: string): URL | null => {
try {
return new URL(s);
} catch {}
try {
return new URL(`https://${s}`);
} catch {}
return null;
};
const fromParts = (path: string): string | null => {
const parts = path.split('/').filter(Boolean);
const briefsIdx = parts.lastIndexOf('briefs');
const candidate =
briefsIdx >= 0 && parts.length > briefsIdx + 1
? parts[briefsIdx + 1]
: parts[parts.length - 1];
return candidate?.trim() || null;
};
// 1) URL (absolute or schemeless)
const url = parseUrl(raw);
if (url) {
const qId = url.searchParams.get('id') || url.searchParams.get('briefId');
const candidate = (qId || fromParts(url.pathname)) ?? null;
if (candidate) {
return candidate;
}
}
// 2) Looks like a path without scheme
if (raw.includes('/')) {
const candidate = fromParts(raw);
if (candidate) {
return candidate;
}
}
// 3) Fallback: raw token (UUID, last 8 chars, or name)
return raw;
}
/**
* Switch to a different brief by name or ID
* Validates context, finds matching brief, and updates auth context
*/
async switchBrief(briefNameOrId: string): Promise<void> {
// Use resolveBrief to find the brief
const matchingBrief = await this.resolveBrief(briefNameOrId);
// Update context with the found brief
await this.authManager.updateContext({
briefId: matchingBrief.id,
briefName:
matchingBrief.document?.title || `Brief ${matchingBrief.id.slice(-8)}`,
briefStatus: matchingBrief.status,
briefUpdatedAt: matchingBrief.updatedAt
});
}
/**
* Get all briefs with detailed statistics including task counts
* Used for API storage to show brief statistics
*/
async getBriefsWithStats(
repository: TaskRepository,
projectId: string
): Promise<{
tags: TagWithStats[];
currentTag: string | null;
totalTags: number;
}> {
const context = this.authManager.getContext();
if (!context?.orgId) {
throw new TaskMasterError(
'No organization context available',
ERROR_CODES.MISSING_CONFIGURATION,
{
operation: 'getBriefsWithStats',
userMessage:
'No organization selected. Please authenticate first using: tm auth login'
}
);
}
// Get all briefs for the organization (through auth manager)
const briefs = await this.authManager.getBriefs(context.orgId);
// Use BriefService to calculate stats
return this.briefService.getTagsWithStats(
briefs,
context.briefId,
repository,
projectId
);
}
}

View File

@@ -0,0 +1,7 @@
/**
* Briefs module exports
*/
export { BriefsDomain } from './briefs-domain.js';
export { BriefService, type TagWithStats } from './services/brief-service.js';
export type { Brief } from './types.js';

View File

@@ -0,0 +1,225 @@
/**
* @fileoverview Brief Service
* Handles brief lookup, matching, and statistics
*/
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import type { Brief } from '../types.js';
/**
* Tag statistics with detailed breakdown
*/
export interface TagWithStats {
name: string;
isCurrent: boolean;
taskCount: number;
completedTasks: number;
statusBreakdown: Record<string, number>;
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
created?: string;
description?: string;
status?: string;
briefId?: string;
updatedAt?: string;
}
/**
* Service for brief-related operations
*/
export class BriefService {
/**
* Find a brief by name or ID with flexible matching
*/
async findBrief(
briefs: Brief[],
nameOrId: string
): Promise<Brief | undefined> {
return briefs.find((brief) => this.matches(brief, nameOrId));
}
/**
* Match a brief against a query string
* Supports: exact name match, partial name match, full ID, last 8 chars of ID
*/
private matches(brief: Brief, query: string): boolean {
const briefName = brief.document?.title || '';
// Exact match (case-insensitive)
if (briefName.toLowerCase() === query.toLowerCase()) {
return true;
}
// Partial match
if (briefName.toLowerCase().includes(query.toLowerCase())) {
return true;
}
// Match by ID (full or last 8 chars)
if (
brief.id === query ||
brief.id.toLowerCase() === query.toLowerCase() ||
brief.id.slice(-8).toLowerCase() === query.toLowerCase()
) {
return true;
}
return false;
}
/**
* Get tags with detailed statistics for all briefs in an organization
* Used for API storage to show brief statistics
*/
async getTagsWithStats(
briefs: Brief[],
currentBriefId: string | undefined,
repository: TaskRepository,
_projectId?: string
): Promise<{
tags: TagWithStats[];
currentTag: string | null;
totalTags: number;
}> {
// For each brief, get task counts by querying tasks
const tagsWithStats = await Promise.all(
briefs.map(async (brief: Brief) => {
try {
// Get all tasks for this brief
const tasks = await repository.getTasks(brief.id, {});
// Calculate statistics
const statusBreakdown: Record<string, number> = {};
let completedTasks = 0;
const subtaskCounts = {
totalSubtasks: 0,
subtasksByStatus: {} as Record<string, number>
};
tasks.forEach((task) => {
// Count task status
const status = task.status || 'pending';
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
if (status === 'done') {
completedTasks++;
}
// Count subtasks
if (task.subtasks && task.subtasks.length > 0) {
subtaskCounts.totalSubtasks += task.subtasks.length;
task.subtasks.forEach((subtask) => {
const subStatus = subtask.status || 'pending';
subtaskCounts.subtasksByStatus[subStatus] =
(subtaskCounts.subtasksByStatus[subStatus] || 0) + 1;
});
}
});
return {
name:
brief.document?.title ||
brief.document?.document_name ||
brief.id,
isCurrent: currentBriefId === brief.id,
taskCount: tasks.length,
completedTasks,
statusBreakdown,
subtaskCounts:
subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined,
created: brief.createdAt,
description: brief.document?.description,
status: brief.status,
briefId: brief.id,
updatedAt: brief.updatedAt
};
} catch (error) {
// If we can't get tasks for a brief, return it with 0 tasks
console.warn(`Failed to get tasks for brief ${brief.id}:`, error);
return {
name:
brief.document?.title ||
brief.document?.document_name ||
brief.id,
isCurrent: currentBriefId === brief.id,
taskCount: 0,
completedTasks: 0,
statusBreakdown: {},
created: brief.createdAt,
description: brief.document?.description,
status: brief.status,
briefId: brief.id,
updatedAt: brief.updatedAt
};
}
})
);
// Define priority order for brief statuses
const statusPriority: Record<string, number> = {
delivering: 1,
aligned: 2,
refining: 3,
draft: 4,
delivered: 5,
done: 6,
archived: 7
};
// Sort tags: first by status priority, then by updatedAt (most recent first) within each status
const sortedTags = tagsWithStats.sort((a, b) => {
// Get status priorities (default to 999 for unknown statuses)
const statusA = (a.status || '').toLowerCase();
const statusB = (b.status || '').toLowerCase();
const priorityA = statusPriority[statusA] ?? 999;
const priorityB = statusPriority[statusB] ?? 999;
// Sort by status priority first
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Within same status, sort by updatedAt (most recent first)
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return dateB - dateA; // Descending order (most recent first)
});
// Find current brief name
const currentBrief = briefs.find((b) => b.id === currentBriefId);
const currentTag = currentBrief
? currentBrief.document?.title ||
currentBrief.document?.document_name ||
null
: null;
return {
tags: sortedTags,
currentTag,
totalTags: sortedTags.length
};
}
/**
* Validate that a brief was found, throw error if not
*/
validateBriefFound(
brief: Brief | undefined,
nameOrId: string
): asserts brief is Brief {
if (!brief) {
throw new TaskMasterError(
`Brief "${nameOrId}" not found in organization`,
ERROR_CODES.NOT_FOUND
);
}
}
}

View File

@@ -0,0 +1,23 @@
/**
* @fileoverview Briefs module types
*/
/**
* Brief data structure
* Represents a project brief containing tasks and requirements
*/
export interface Brief {
id: string;
accountId: string;
documentId: string;
status: string;
createdAt: string;
updatedAt: string;
taskCount?: number;
document?: {
id: string;
title: string;
document_name: string;
description?: string;
};
}

View File

@@ -3,11 +3,11 @@
* Public API for configuration management
*/
import type { ConfigManager } from './managers/config-manager.js';
import type {
PartialConfiguration,
RuntimeStorageConfig
} from '../../common/interfaces/configuration.interface.js';
import type { ConfigManager } from './managers/config-manager.js';
/**
* Config Domain - Unified API for configuration operations

View File

@@ -3,14 +3,14 @@
* Tests the orchestration of all configuration services
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ConfigManager } from './config-manager.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from '../services/config-loader.service.js';
import { ConfigMerger } from '../services/config-merger.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigPersistence } from '../services/config-persistence.service.js';
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigManager } from './config-manager.js';
// Mock all services
vi.mock('../services/config-loader.service.js');

View File

@@ -13,12 +13,12 @@ import type {
import { DEFAULT_CONFIG_VALUES as DEFAULTS } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from '../services/config-loader.service.js';
import {
ConfigMerger,
CONFIG_PRECEDENCE
CONFIG_PRECEDENCE,
ConfigMerger
} from '../services/config-merger.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
import { ConfigPersistence } from '../services/config-persistence.service.js';
import { EnvironmentConfigProvider } from '../services/environment-config-provider.service.js';
import { RuntimeStateManager } from '../services/runtime-state-manager.service.js';
/**
* ConfigManager orchestrates all configuration services

View File

@@ -2,10 +2,10 @@
* @fileoverview Unit tests for ConfigLoader service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { ConfigLoader } from './config-loader.service.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { ConfigLoader } from './config-loader.service.js';
vi.mock('node:fs', () => ({
promises: {

View File

@@ -5,12 +5,12 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
/**
* ConfigLoader handles loading configuration from files

View File

@@ -2,8 +2,8 @@
* @fileoverview Unit tests for ConfigMerger service
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { ConfigMerger, CONFIG_PRECEDENCE } from './config-merger.service.js';
import { beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_PRECEDENCE, ConfigMerger } from './config-merger.service.js';
describe('ConfigMerger', () => {
let merger: ConfigMerger;

View File

@@ -2,10 +2,10 @@
* @fileoverview Unit tests for ConfigPersistence service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { ConfigPersistence } from './config-persistence.service.js';
import type { PartialConfiguration } from '@tm/core/common/interfaces/configuration.interface.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConfigPersistence } from './config-persistence.service.js';
vi.mock('node:fs', () => ({
promises: {

View File

@@ -5,11 +5,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { PartialConfiguration } from '../../../common/interfaces/configuration.interface.js';
import { getLogger } from '../../../common/logger/index.js';
/**

View File

@@ -2,7 +2,7 @@
* @fileoverview Unit tests for EnvironmentConfigProvider service
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EnvironmentConfigProvider } from './environment-config-provider.service.js';
describe('EnvironmentConfigProvider', () => {

View File

@@ -2,10 +2,10 @@
* @fileoverview Unit tests for RuntimeStateManager service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import fs from 'node:fs/promises';
import { RuntimeStateManager } from './runtime-state-manager.service.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG_VALUES } from '../../../common/interfaces/configuration.interface.js';
import { RuntimeStateManager } from './runtime-state-manager.service.js';
vi.mock('node:fs', () => ({
promises: {

View File

@@ -2,9 +2,9 @@
* Base executor class providing common functionality for all executors
*/
import type { Task } from '../../../common/types/index.js';
import type { ITaskExecutor, ExecutorType, ExecutionResult } from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
import type { Task } from '../../../common/types/index.js';
import type { ExecutionResult, ExecutorType, ITaskExecutor } from '../types.js';
export abstract class BaseExecutor implements ITaskExecutor {
protected readonly logger = getLogger('BaseExecutor');

View File

@@ -4,12 +4,12 @@
import { spawn } from 'child_process';
import type { Task } from '../../../common/types/index.js';
import type {
ExecutorType,
ExecutionResult,
ClaudeExecutorConfig
} from '../types.js';
import { BaseExecutor } from '../executors/base-executor.js';
import type {
ClaudeExecutorConfig,
ExecutionResult,
ExecutorType
} from '../types.js';
export class ClaudeExecutor extends BaseExecutor {
private claudeConfig: ClaudeExecutorConfig;

View File

@@ -2,9 +2,9 @@
* Factory for creating task executors
*/
import type { ITaskExecutor, ExecutorOptions, ExecutorType } from '../types.js';
import { ClaudeExecutor } from '../executors/claude-executor.js';
import { getLogger } from '../../../common/logger/index.js';
import { ClaudeExecutor } from '../executors/claude-executor.js';
import type { ExecutorOptions, ExecutorType, ITaskExecutor } from '../types.js';
export class ExecutorFactory {
private static logger = getLogger('ExecutorFactory');

View File

@@ -2,15 +2,15 @@
* Service for managing task execution
*/
import type { Task } from '../../../common/types/index.js';
import type {
ITaskExecutor,
ExecutorOptions,
ExecutionResult,
ExecutorType
} from '../types.js';
import { ExecutorFactory } from '../executors/executor-factory.js';
import { getLogger } from '../../../common/logger/index.js';
import type { Task } from '../../../common/types/index.js';
import { ExecutorFactory } from '../executors/executor-factory.js';
import type {
ExecutionResult,
ExecutorOptions,
ExecutorType,
ITaskExecutor
} from '../types.js';
export interface ExecutorServiceOptions {
projectRoot: string;

View File

@@ -1,14 +1,14 @@
import os from 'os';
import path from 'path';
import {
describe,
it,
expect,
beforeEach,
afterEach,
beforeEach,
describe,
expect,
it,
jest
} from '@jest/globals';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { GitAdapter } from '../../../../../packages/tm-core/src/git/git-adapter.js';
describe('GitAdapter - Repository Detection and Validation', () => {

View File

@@ -5,9 +5,9 @@
* @module git-adapter
*/
import { simpleGit, type SimpleGit, type StatusResult } from 'simple-git';
import fs from 'fs-extra';
import path from 'path';
import fs from 'fs-extra';
import { type SimpleGit, type StatusResult, simpleGit } from 'simple-git';
/**
* GitAdapter class for safe git operations

View File

@@ -3,10 +3,10 @@
* Public API for Git operations
*/
import type { StatusResult } from 'simple-git';
import { GitAdapter } from './adapters/git-adapter.js';
import { CommitMessageGenerator } from './services/commit-message-generator.js';
import type { CommitMessageOptions } from './services/commit-message-generator.js';
import type { StatusResult } from 'simple-git';
/**
* Git Domain - Unified API for Git operations

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
generateBranchName,
sanitizeBranchName

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { CommitMessageGenerator } from './commit-message-generator.js';
describe('CommitMessageGenerator', () => {

View File

@@ -5,8 +5,8 @@
* that follow conventional commits specification and include task metadata.
*/
import { TemplateEngine } from './template-engine.js';
import { ScopeDetector } from './scope-detector.js';
import { TemplateEngine } from './template-engine.js';
export interface CommitMessageOptions {
type: string;

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { ScopeDetector } from './scope-detector.js';
describe('ScopeDetector', () => {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { TemplateEngine } from './template-engine.js';
describe('TemplateEngine', () => {

View File

@@ -3,14 +3,14 @@
*/
import {
createClient,
Session,
SupabaseClient as SupabaseJSClient,
User,
Session
createClient
} from '@supabase/supabase-js';
import { AuthenticationError } from '../../auth/types.js';
import { getLogger } from '../../../common/logger/index.js';
import { SupabaseSessionStorage } from '../../auth/services/supabase-session-storage.js';
import { AuthenticationError } from '../../auth/types.js';
export class SupabaseAuthClient {
private client: SupabaseJSClient | null = null;

View File

@@ -3,12 +3,12 @@
* Public API for integration with external systems
*/
import type { ConfigManager } from '../config/managers/config-manager.js';
import { AuthManager } from '../auth/managers/auth-manager.js';
import type { ConfigManager } from '../config/managers/config-manager.js';
import { ExportService } from './services/export.service.js';
import type {
ExportTasksOptions,
ExportResult
ExportResult,
ExportTasksOptions
} from './services/export.service.js';
/**

View File

@@ -3,14 +3,14 @@
* Core service for exporting tasks to external systems (e.g., Hamster briefs)
*/
import type { Task, TaskStatus } from '../../../common/types/index.js';
import type { UserContext } from '../../auth/types.js';
import { ConfigManager } from '../../config/managers/config-manager.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { Task, TaskStatus } from '../../../common/types/index.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import type { UserContext } from '../../auth/types.js';
import { ConfigManager } from '../../config/managers/config-manager.js';
import { FileStorage } from '../../storage/adapters/file-storage/index.js';
// Type definitions for the bulk API response

View File

@@ -8,10 +8,10 @@ import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import { getLogger } from '../../../common/logger/factory.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../../storage/utils/api-client.js';
import { getLogger } from '../../../common/logger/factory.js';
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
/**
* Response from the expand task API endpoint (202 Accepted)

View File

@@ -8,11 +8,11 @@ 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';
import type { Task } from '../../../common/types/index.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../../storage/utils/api-client.js';
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
/**
* Response from the get task API endpoint

View File

@@ -5,12 +5,12 @@
import fs from 'node:fs/promises';
import path from 'path';
import { getLogger } from '../../../common/logger/index.js';
import type {
ComplexityReport,
ComplexityAnalysis,
ComplexityReport,
TaskComplexityData
} from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
const logger = getLogger('ComplexityReportManager');

View File

@@ -5,8 +5,8 @@
* @module activity-logger
*/
import fs from 'fs-extra';
import path from 'path';
import fs from 'fs-extra';
/**
* Activity log entry structure

View File

@@ -3,33 +3,34 @@
* This provides storage via repository abstraction for flexibility
*/
import type {
IStorage,
StorageStats,
UpdateStatusResult,
LoadTasksOptions
} from '../../../common/interfaces/storage.interface.js';
import type {
Task,
TaskMetadata,
TaskTag,
TaskStatus
} from '../../../common/types/index.js';
import type { SupabaseClient } from '@supabase/supabase-js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js';
import { SupabaseClient } from '@supabase/supabase-js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { ApiClient } from '../utils/api-client.js';
import type {
IStorage,
LoadTasksOptions,
StorageStats,
UpdateStatusResult
} from '../../../common/interfaces/storage.interface.js';
import { getLogger } from '../../../common/logger/factory.js';
import type {
Task,
TaskMetadata,
TaskStatus,
TaskTag
} from '../../../common/types/index.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { BriefsDomain } from '../../briefs/briefs-domain.js';
import {
ExpandTaskResult,
type ExpandTaskResult,
TaskExpansionService
} from '../../integration/services/task-expansion.service.js';
import { TaskRetrievalService } from '../../integration/services/task-retrieval.service.js';
import { SupabaseRepository } from '../../tasks/repositories/supabase/index.js';
import type { TaskRepository } from '../../tasks/repositories/task-repository.interface.js';
import { ApiClient } from '../utils/api-client.js';
/**
* API storage configuration
@@ -159,6 +160,49 @@ export class ApiStorage implements IStorage {
return context?.briefName || null;
}
/**
* Get all briefs (tags) with detailed statistics including task counts
* In API storage, tags are called "briefs"
* Delegates to BriefsDomain for brief statistics calculation
*/
async getTagsWithStats(): Promise<{
tags: Array<{
name: string;
isCurrent: boolean;
taskCount: number;
completedTasks: number;
statusBreakdown: Record<string, number>;
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
created?: string;
description?: string;
status?: string;
briefId?: string;
}>;
currentTag: string | null;
totalTags: number;
}> {
await this.ensureInitialized();
try {
// Delegate to BriefsDomain which owns brief operations
const briefsDomain = new BriefsDomain();
return await briefsDomain.getBriefsWithStats(
this.repository,
this.projectId
);
} catch (error) {
throw new TaskMasterError(
'Failed to get tags with stats from API',
ERROR_CODES.STORAGE_ERROR,
{ operation: 'getTagsWithStats' },
error as Error
);
}
}
/**
* Load tags into cache
* In our API-based system, "tags" represent briefs
@@ -684,6 +728,21 @@ export class ApiStorage implements IStorage {
return this.listTags();
}
/**
* Create a new tag (brief)
* Not supported with API storage - users must create briefs via web interface
*/
async createTag(
tagName: string,
_options?: { copyFrom?: string; description?: string }
): Promise<void> {
throw new TaskMasterError(
'Tag creation is not supported with API storage. Please create briefs through Hamster Studio.',
ERROR_CODES.NOT_IMPLEMENTED,
{ storageType: 'api', operation: 'createTag', tagName }
);
}
/**
* Delete all tasks for a tag
*/
@@ -964,7 +1023,7 @@ export class ApiStorage implements IStorage {
*/
private async retryOperation<T>(
operation: () => Promise<T>,
attempt: number = 1
attempt = 1
): Promise<T> {
try {
return await operation();

View File

@@ -2,8 +2,8 @@
* @fileoverview File operations with atomic writes and locking
*/
import fs from 'node:fs/promises';
import { constants } from 'node:fs';
import fs from 'node:fs/promises';
import type { FileStorageData } from './format-handler.js';
/**

View File

@@ -2,21 +2,26 @@
* @fileoverview Refactored file-based storage implementation for Task Master
*/
import path from 'node:path';
import type {
IStorage,
LoadTasksOptions,
StorageStats,
UpdateStatusResult
} from '../../../../common/interfaces/storage.interface.js';
import type {
Task,
TaskMetadata,
TaskStatus
} from '../../../../common/types/index.js';
import type {
IStorage,
StorageStats,
UpdateStatusResult,
LoadTasksOptions
} from '../../../../common/interfaces/storage.interface.js';
import { FormatHandler } from './format-handler.js';
import { FileOperations } from './file-operations.js';
import { PathResolver } from './path-resolver.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../../common/errors/task-master-error.js';
import { ComplexityReportManager } from '../../../reports/managers/complexity-report-manager.js';
import { FileOperations } from './file-operations.js';
import { FormatHandler } from './format-handler.js';
import { PathResolver } from './path-resolver.js';
/**
* File-based storage implementation using a single tasks.json file with separated concerns
@@ -583,6 +588,94 @@ export class FileStorage implements IStorage {
await this.saveTasks(filteredTasks, tag);
}
/**
* Create a new tag in the tasks.json file
*/
async createTag(
tagName: string,
options?: { copyFrom?: string; description?: string }
): Promise<void> {
const filePath = this.pathResolver.getTasksPath();
try {
const existingData = await this.fileOps.readJson(filePath);
const format = this.formatHandler.detectFormat(existingData);
if (format === 'legacy') {
// Legacy format - add new tag key
if (tagName in existingData) {
throw new TaskMasterError(
`Tag ${tagName} already exists`,
ERROR_CODES.VALIDATION_ERROR
);
}
// Get tasks to copy if specified
let tasksToCopy = [];
if (options?.copyFrom) {
if (
options.copyFrom in existingData &&
existingData[options.copyFrom].tasks
) {
tasksToCopy = JSON.parse(
JSON.stringify(existingData[options.copyFrom].tasks)
);
}
}
// Create new tag structure
existingData[tagName] = {
tasks: tasksToCopy,
metadata: {
created: new Date().toISOString(),
updatedAt: new Date().toISOString(),
description:
options?.description ||
`Tag created on ${new Date().toLocaleDateString()}`,
tags: [tagName]
}
};
await this.fileOps.writeJson(filePath, existingData);
} else {
// Standard format - need to convert to legacy format first
const masterTasks = existingData.tasks || [];
const masterMetadata = existingData.metadata || {};
// Get tasks to copy (from master in this case)
let tasksToCopy = [];
if (options?.copyFrom === 'master' || !options?.copyFrom) {
tasksToCopy = JSON.parse(JSON.stringify(masterTasks));
}
const newData = {
master: {
tasks: masterTasks,
metadata: { ...masterMetadata, tags: ['master'] }
},
[tagName]: {
tasks: tasksToCopy,
metadata: {
created: new Date().toISOString(),
updatedAt: new Date().toISOString(),
description:
options?.description ||
`Tag created on ${new Date().toLocaleDateString()}`,
tags: [tagName]
}
}
};
await this.fileOps.writeJson(filePath, newData);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw new Error('Tasks file not found - initialize project first');
}
throw error;
}
}
/**
* Delete a tag from the single tasks.json file
*/
@@ -675,6 +768,120 @@ export class FileStorage implements IStorage {
await this.saveTasks(tasks, targetTag);
}
/**
* Get all tags with detailed statistics including task counts
* For file storage, reads tags from tasks.json and calculates statistics
*/
async getTagsWithStats(): Promise<{
tags: Array<{
name: string;
isCurrent: boolean;
taskCount: number;
completedTasks: number;
statusBreakdown: Record<string, number>;
subtaskCounts?: {
totalSubtasks: number;
subtasksByStatus: Record<string, number>;
};
created?: string;
description?: string;
}>;
currentTag: string | null;
totalTags: number;
}> {
const availableTags = await this.getAllTags();
// Get active tag from state.json
const activeTag = await this.getActiveTagFromState();
const tagsWithStats = await Promise.all(
availableTags.map(async (tagName) => {
try {
// Load tasks for this tag
const tasks = await this.loadTasks(tagName);
// Calculate statistics
const statusBreakdown: Record<string, number> = {};
let completedTasks = 0;
const subtaskCounts = {
totalSubtasks: 0,
subtasksByStatus: {} as Record<string, number>
};
tasks.forEach((task) => {
// Count task status
const status = task.status || 'pending';
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
if (status === 'done') {
completedTasks++;
}
// Count subtasks
if (task.subtasks && task.subtasks.length > 0) {
subtaskCounts.totalSubtasks += task.subtasks.length;
task.subtasks.forEach((subtask) => {
const subStatus = subtask.status || 'pending';
subtaskCounts.subtasksByStatus[subStatus] =
(subtaskCounts.subtasksByStatus[subStatus] || 0) + 1;
});
}
});
// Load metadata to get created date and description
const metadata = await this.loadMetadata(tagName);
return {
name: tagName,
isCurrent: tagName === activeTag,
taskCount: tasks.length,
completedTasks,
statusBreakdown,
subtaskCounts:
subtaskCounts.totalSubtasks > 0 ? subtaskCounts : undefined,
created: metadata?.created,
description: metadata?.description
};
} catch (error) {
// If we can't load tasks for a tag, return it with 0 tasks
return {
name: tagName,
isCurrent: tagName === activeTag,
taskCount: 0,
completedTasks: 0,
statusBreakdown: {}
};
}
})
);
return {
tags: tagsWithStats,
currentTag: activeTag,
totalTags: tagsWithStats.length
};
}
/**
* Get the active tag from state.json
* @returns The active tag name or 'master' as default
*/
private async getActiveTagFromState(): Promise<string> {
try {
const statePath = path.join(
this.pathResolver.getBasePath(),
'state.json'
);
const stateData = await this.fileOps.readJson(statePath);
return stateData?.currentTag || 'master';
} catch (error) {
// If state.json doesn't exist or can't be read, default to 'master'
return 'master';
}
}
/**
* Enrich tasks with complexity data from the complexity report
* Private helper method called by loadTasks()

View File

@@ -2,21 +2,21 @@
* @fileoverview Storage factory for creating appropriate storage implementations
*/
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type {
IConfiguration,
RuntimeStorageConfig,
StorageSettings
} from '../../../common/interfaces/configuration.interface.js';
import { FileStorage } from '../adapters/file-storage/index.js';
import { ApiStorage } from '../adapters/api-storage.js';
import {
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
import { getLogger } from '../../../common/logger/index.js';
import { AuthManager } from '../../auth/managers/auth-manager.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { ApiStorage } from '../adapters/api-storage.js';
import { FileStorage } from '../adapters/file-storage/index.js';
/**
* Factory for creating storage implementations based on configuration
@@ -87,8 +87,19 @@ export class StorageFactory {
apiEndpoint:
config.storage?.apiEndpoint ||
process.env.TM_BASE_DOMAIN ||
process.env.TM_PUBLIC_BASE_DOMAIN
process.env.TM_PUBLIC_BASE_DOMAIN ||
'https://tryhamster.com/api'
};
// Validate that apiEndpoint is defined
if (!nextStorage.apiEndpoint) {
throw new TaskMasterError(
'API endpoint could not be determined.',
ERROR_CODES.MISSING_CONFIGURATION,
{ storageType: 'api' }
);
}
config.storage = nextStorage;
}
}

View File

@@ -4,8 +4,8 @@
*/
import {
TaskMasterError,
ERROR_CODES
ERROR_CODES,
TaskMasterError
} from '../../../common/errors/task-master-error.js';
import type { AuthManager } from '../../auth/managers/auth-manager.js';

View File

@@ -0,0 +1,307 @@
/**
* @fileoverview TagService - Business logic for tag management
* Handles tag creation, deletion, renaming, and copying
*/
import type { IStorage } from '../../../common/interfaces/storage.interface.js';
import type { TagInfo } from '../../../common/interfaces/storage.interface.js';
import { TaskMasterError, ERROR_CODES } from '../../../common/errors/task-master-error.js';
/**
* Options for creating a new tag
*/
export interface CreateTagOptions {
/** Copy tasks from current tag */
copyFromCurrent?: boolean;
/** Copy tasks from specific tag */
copyFromTag?: string;
/** Tag description */
description?: string;
/** Create from git branch name */
fromBranch?: boolean;
}
/**
* Options for deleting a tag
* Note: Confirmation prompts are a CLI presentation concern
* and are not handled by TagService (business logic layer)
*/
export interface DeleteTagOptions {
// Currently no options - interface kept for future extensibility
}
/**
* Options for copying a tag
*/
export interface CopyTagOptions {
// Currently no options - interface kept for future extensibility
}
/**
* Reserved tag names that cannot be used
* Only 'master' is reserved as it's the system default tag
* Users can use 'main' or 'default' if desired
*/
const RESERVED_TAG_NAMES = ['master'];
/**
* Maximum length for tag names (prevents filesystem/UI issues)
*/
const MAX_TAG_NAME_LENGTH = 50;
/**
* TagService - Handles tag management business logic
* Validates operations and delegates to storage layer
*/
export class TagService {
constructor(private storage: IStorage) {}
/**
* Validate tag name format and restrictions
* @throws {TaskMasterError} if validation fails
*/
private validateTagName(name: string, context = 'Tag name'): void {
if (!name || typeof name !== 'string') {
throw new TaskMasterError(
`${context} is required and must be a string`,
ERROR_CODES.VALIDATION_ERROR
);
}
// Check length
if (name.length > MAX_TAG_NAME_LENGTH) {
throw new TaskMasterError(
`${context} must be ${MAX_TAG_NAME_LENGTH} characters or less`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: name, maxLength: MAX_TAG_NAME_LENGTH }
);
}
// Check format: alphanumeric, hyphens, underscores only
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new TaskMasterError(
`${context} can only contain letters, numbers, hyphens, and underscores`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: name }
);
}
// Check reserved names
if (RESERVED_TAG_NAMES.includes(name.toLowerCase())) {
throw new TaskMasterError(
`"${name}" is a reserved tag name`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: name, reserved: true }
);
}
}
/**
* Check if storage supports tag mutation operations
* @throws {TaskMasterError} if operation not supported
*/
private checkTagMutationSupport(operation: string): void {
const storageType = this.storage.getStorageType();
if (storageType === 'api') {
throw new TaskMasterError(
`${operation} is not supported with API storage. Use the web interface at Hamster Studio.`,
ERROR_CODES.NOT_IMPLEMENTED,
{ storageType: 'api', operation }
);
}
}
/**
* Create a new tag
* For API storage: throws error (client should redirect to web UI)
* For file storage: creates tag with optional task copying
*/
async createTag(
name: string,
options: CreateTagOptions = {}
): Promise<TagInfo> {
// Validate tag name
this.validateTagName(name);
// Check if tag already exists
const allTags = await this.storage.getAllTags();
if (allTags.includes(name)) {
throw new TaskMasterError(
`Tag "${name}" already exists`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: name }
);
}
// Validate copyFromTag if provided
if (options.copyFromTag && !allTags.includes(options.copyFromTag)) {
throw new TaskMasterError(
`Cannot copy from missing tag "${options.copyFromTag}"`,
ERROR_CODES.NOT_FOUND,
{ tagName: options.copyFromTag }
);
}
// For API storage, we can't create tags via CLI
// The client (CLI/bridge) should handle redirecting to web UI
this.checkTagMutationSupport('Tag creation');
// Determine which tag to copy from
let copyFrom: string | undefined;
if (options.copyFromTag) {
copyFrom = options.copyFromTag;
} else if (options.copyFromCurrent) {
const result = await this.storage.getTagsWithStats();
copyFrom = result.currentTag || undefined;
}
// Delegate to storage layer
await this.storage.createTag(name, {
copyFrom,
description: options.description
});
// Return tag info
const tagInfo: TagInfo = {
name,
taskCount: 0,
completedTasks: 0,
isCurrent: false,
statusBreakdown: {},
description: options.description || `Tag created on ${new Date().toLocaleDateString()}`
};
return tagInfo;
}
/**
* Delete an existing tag
* Cannot delete master tag
* For API storage: throws error (client should redirect to web UI)
*/
async deleteTag(
name: string,
_options: DeleteTagOptions = {}
): Promise<void> {
// Validate tag name
this.validateTagName(name);
// Cannot delete master tag
if (name === 'master') {
throw new TaskMasterError(
'Cannot delete the "master" tag',
ERROR_CODES.VALIDATION_ERROR,
{ tagName: name, protected: true }
);
}
// For API storage, we can't delete tags via CLI
this.checkTagMutationSupport('Tag deletion');
// Check if tag exists
const allTags = await this.storage.getAllTags();
if (!allTags.includes(name)) {
throw new TaskMasterError(
`Tag "${name}" does not exist`,
ERROR_CODES.NOT_FOUND,
{ tagName: name }
);
}
// Delegate to storage
await this.storage.deleteTag(name);
}
/**
* Rename an existing tag
* Cannot rename master tag
* For API storage: throws error (client should redirect to web UI)
*/
async renameTag(oldName: string, newName: string): Promise<void> {
// Validate both names
this.validateTagName(oldName, 'Old tag name');
this.validateTagName(newName, 'New tag name');
// Cannot rename master tag
if (oldName === 'master') {
throw new TaskMasterError(
'Cannot rename the "master" tag',
ERROR_CODES.VALIDATION_ERROR,
{ tagName: oldName, protected: true }
);
}
// For API storage, we can't rename tags via CLI
this.checkTagMutationSupport('Tag renaming');
// Check if old tag exists
const allTags = await this.storage.getAllTags();
if (!allTags.includes(oldName)) {
throw new TaskMasterError(
`Tag "${oldName}" does not exist`,
ERROR_CODES.NOT_FOUND,
{ tagName: oldName }
);
}
// Check if new name already exists
if (allTags.includes(newName)) {
throw new TaskMasterError(
`Tag "${newName}" already exists`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: newName }
);
}
// Delegate to storage
await this.storage.renameTag(oldName, newName);
}
/**
* Copy an existing tag to create a new tag with the same tasks
* For API storage: throws error (client should show alternative)
*/
async copyTag(
sourceName: string,
targetName: string,
_options: CopyTagOptions = {}
): Promise<void> {
// Validate both names
this.validateTagName(sourceName, 'Source tag name');
this.validateTagName(targetName, 'Target tag name');
// For API storage, we can't copy tags via CLI
this.checkTagMutationSupport('Tag copying');
// Check if source tag exists
const allTags = await this.storage.getAllTags();
if (!allTags.includes(sourceName)) {
throw new TaskMasterError(
`Source tag "${sourceName}" does not exist`,
ERROR_CODES.NOT_FOUND,
{ tagName: sourceName }
);
}
// Check if target name already exists
if (allTags.includes(targetName)) {
throw new TaskMasterError(
`Target tag "${targetName}" already exists`,
ERROR_CODES.VALIDATION_ERROR,
{ tagName: targetName }
);
}
// Delegate to storage
await this.storage.copyTag(sourceName, targetName);
}
/**
* Get all tags with statistics
* Works with both file and API storage
*/
async getTagsWithStats() {
return await this.storage.getTagsWithStats();
}
}

View File

@@ -500,6 +500,14 @@ export class TaskService {
return this.storage.getStorageType();
}
/**
* Get the storage instance
* Internal use only - used by other services in the tasks module
*/
getStorage(): IStorage {
return this.storage;
}
/**
* Get current active tag
*/
@@ -758,4 +766,45 @@ export class TaskService {
);
}
}
/**
* Get all tags with detailed statistics including task counts
* Delegates to storage layer which handles file vs API implementation
*/
async getTagsWithStats() {
// 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();
}
try {
return await this.storage.getTagsWithStats();
} catch (error) {
// If it's a user-facing error (like NO_BRIEF_SELECTED), don't wrap it
if (
error instanceof TaskMasterError &&
error.is(ERROR_CODES.NO_BRIEF_SELECTED)
) {
throw error;
}
throw new TaskMasterError(
'Failed to get tags with stats',
ERROR_CODES.STORAGE_ERROR,
{
operation: 'getTagsWithStats',
resource: 'tags'
},
error as Error
);
}
}
}

View File

@@ -4,10 +4,18 @@
*/
import type { ConfigManager } from '../config/managers/config-manager.js';
import type { AuthDomain } from '../auth/auth-domain.js';
import { BriefsDomain } from '../briefs/briefs-domain.js';
import { TaskService } from './services/task-service.js';
import { TaskExecutionService } from './services/task-execution-service.js';
import { TaskLoaderService } from './services/task-loader.service.js';
import { PreflightChecker } from './services/preflight-checker.service.js';
import { TagService } from './services/tag.service.js';
import type {
CreateTagOptions,
DeleteTagOptions,
CopyTagOptions
} from './services/tag.service.js';
import type { Subtask, Task, TaskStatus } from '../../common/types/index.js';
import type {
@@ -32,16 +40,22 @@ export class TasksDomain {
private executionService: TaskExecutionService;
private loaderService: TaskLoaderService;
private preflightChecker: PreflightChecker;
private briefsDomain: BriefsDomain;
private tagService!: TagService;
constructor(configManager: ConfigManager) {
constructor(configManager: ConfigManager, _authDomain?: AuthDomain) {
this.taskService = new TaskService(configManager);
this.executionService = new TaskExecutionService(this.taskService);
this.loaderService = new TaskLoaderService(this.taskService);
this.preflightChecker = new PreflightChecker(configManager.getProjectRoot());
this.briefsDomain = new BriefsDomain();
}
async initialize(): Promise<void> {
await this.taskService.initialize();
// TagService needs storage - get it from TaskService AFTER initialization
this.tagService = new TagService(this.taskService.getStorage());
}
// ========== Task Retrieval ==========
@@ -183,6 +197,40 @@ export class TasksDomain {
return this.taskService.setActiveTag(tag);
}
/**
* Resolve a brief by ID, name, or partial match without switching
* Returns the full brief object
*
* Supports:
* - Full UUID
* - Last 8 characters of UUID
* - Brief name (exact or partial match)
*
* Only works with API storage (briefs).
*
* @param briefIdOrName - Brief identifier
* @param orgId - Optional organization ID
* @returns The resolved brief object
*/
async resolveBrief(briefIdOrName: string, orgId?: string): Promise<any> {
return this.briefsDomain.resolveBrief(briefIdOrName, orgId);
}
/**
* Switch to a different tag/brief context
* For file storage: updates active tag in state
* For API storage: looks up brief by name and updates auth context
*/
async switchTag(tagName: string): Promise<void> {
const storageType = this.taskService.getStorageType();
if (storageType === 'file') {
await this.setActiveTag(tagName);
} else {
await this.briefsDomain.switchBrief(tagName);
}
}
// ========== Task Execution ==========
/**
@@ -266,6 +314,55 @@ export class TasksDomain {
return this.preflightChecker.detectDefaultBranch();
}
// ========== Tag Management ==========
/**
* Create a new tag
* For file storage: creates tag locally with optional task copying
* For API storage: throws error (client should redirect to web UI)
*/
async createTag(name: string, options?: CreateTagOptions) {
return this.tagService.createTag(name, options);
}
/**
* Delete an existing tag
* Cannot delete master tag
* For file storage: deletes tag locally
* For API storage: throws error (client should redirect to web UI)
*/
async deleteTag(name: string, options?: DeleteTagOptions) {
return this.tagService.deleteTag(name, options);
}
/**
* Rename an existing tag
* Cannot rename master tag
* For file storage: renames tag locally
* For API storage: throws error (client should redirect to web UI)
*/
async renameTag(oldName: string, newName: string) {
return this.tagService.renameTag(oldName, newName);
}
/**
* Copy an existing tag to create a new tag with the same tasks
* For file storage: copies tag locally
* For API storage: throws error (client should show alternative)
*/
async copyTag(source: string, target: string, options?: CopyTagOptions) {
return this.tagService.copyTag(source, target, options);
}
/**
* Get all tags with detailed statistics including task counts
* For API storage, returns briefs with task counts
* For file storage, returns tags from tasks.json with counts
*/
async getTagsWithStats() {
return this.tagService.getTagsWithStats();
}
// ========== Storage Information ==========
/**

View File

@@ -2,10 +2,10 @@
* @fileoverview Tests for WorkflowStateManager path sanitization
*/
import { describe, it, expect } from 'vitest';
import { WorkflowStateManager } from './workflow-state-manager.js';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { WorkflowStateManager } from './workflow-state-manager.js';
describe('WorkflowStateManager', () => {
describe('getProjectIdentifier', () => {

View File

@@ -7,11 +7,11 @@
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import path from 'node:path';
import { Writer } from 'steno';
import type { WorkflowState } from '../types.js';
import { getLogger } from '../../../common/logger/index.js';
import type { WorkflowState } from '../types.js';
export interface WorkflowStateBackup {
timestamp: string;

View File

@@ -1,13 +1,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
import type {
WorkflowContext,
WorkflowPhase,
WorkflowEventData,
WorkflowError
} from '../types.js';
import { TestResultValidator } from '../services/test-result-validator.js';
import type { TestResult } from '../services/test-result-validator.types.js';
import type {
WorkflowContext,
WorkflowError,
WorkflowEventData,
WorkflowPhase
} from '../types.js';
describe('WorkflowOrchestrator - State Machine Structure', () => {
let orchestrator: WorkflowOrchestrator;

View File

@@ -1,17 +1,17 @@
import type { TestResultValidator } from '../services/test-result-validator.js';
import type {
WorkflowPhase,
StateTransition,
SubtaskInfo,
TDDPhase,
WorkflowContext,
WorkflowError,
WorkflowEvent,
WorkflowState,
StateTransition,
WorkflowEventType,
WorkflowEventData,
WorkflowEventListener,
SubtaskInfo,
WorkflowError
WorkflowEventType,
WorkflowPhase,
WorkflowState
} from '../types.js';
import type { TestResultValidator } from '../services/test-result-validator.js';
/**
* Lightweight state machine for TDD workflow orchestration

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { TestResultValidator } from './test-result-validator.js';
import type {
TestPhase,
TestResult,
ValidationResult,
TestPhase
ValidationResult
} from './test-result-validator.types.js';
describe('TestResultValidator - Input Validation', () => {

View File

@@ -1,9 +1,9 @@
import { z } from 'zod';
import type {
TestResult,
ValidationResult,
CoverageThresholds,
PhaseValidationOptions
PhaseValidationOptions,
TestResult,
ValidationResult
} from './test-result-validator.types.js';
/**

View File

@@ -5,13 +5,13 @@
* for debugging, auditing, and workflow analysis.
*/
import { getLogger } from '../../../common/logger/index.js';
import {
type ActivityEvent,
logActivity
} from '../../storage/adapters/activity-logger.js';
import type { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
import type { WorkflowEventData, WorkflowEventType } from '../types.js';
import {
logActivity,
type ActivityEvent
} from '../../storage/adapters/activity-logger.js';
import { getLogger } from '../../../common/logger/index.js';
/**
* All workflow event types that should be logged

View File

@@ -3,18 +3,18 @@
* Provides a simplified API for MCP tools while delegating to WorkflowOrchestrator
*/
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
import { GitAdapter } from '../../git/adapters/git-adapter.js';
import { WorkflowStateManager } from '../managers/workflow-state-manager.js';
import { WorkflowActivityLogger } from './workflow-activity-logger.js';
import { WorkflowOrchestrator } from '../orchestrators/workflow-orchestrator.js';
import type {
WorkflowContext,
SubtaskInfo,
TestResult,
WorkflowPhase,
TDDPhase,
TestResult,
WorkflowContext,
WorkflowPhase,
WorkflowState
} from '../types.js';
import { GitAdapter } from '../../git/adapters/git-adapter.js';
import { WorkflowActivityLogger } from './workflow-activity-logger.js';
/**
* Options for starting a new workflow

View File

@@ -6,9 +6,9 @@
import type { ConfigManager } from '../config/managers/config-manager.js';
import { WorkflowService } from './services/workflow.service.js';
import type {
NextAction,
StartWorkflowOptions,
WorkflowStatus,
NextAction
WorkflowStatus
} from './services/workflow.service.js';
import type { TestResult, WorkflowContext } from './types.js';

View File

@@ -3,7 +3,7 @@
* This demonstrates how consumers can use granular imports for better tree-shaking
*/
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
describe('Subpath Exports', () => {
it('should allow importing from auth subpath', async () => {

View File

@@ -4,13 +4,13 @@
*/
import path from 'node:path';
import { ConfigManager } from './modules/config/managers/config-manager.js';
import { TasksDomain } from './modules/tasks/tasks-domain.js';
import { AuthDomain } from './modules/auth/auth-domain.js';
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
import { GitDomain } from './modules/git/git-domain.js';
import { ConfigDomain } from './modules/config/config-domain.js';
import { ConfigManager } from './modules/config/managers/config-manager.js';
import { GitDomain } from './modules/git/git-domain.js';
import { IntegrationDomain } from './modules/integration/integration-domain.js';
import { TasksDomain } from './modules/tasks/tasks-domain.js';
import { WorkflowDomain } from './modules/workflow/workflow-domain.js';
import {
ERROR_CODES,
@@ -18,9 +18,9 @@ import {
} from './common/errors/task-master-error.js';
import type { IConfiguration } from './common/interfaces/configuration.interface.js';
import {
createLogger,
type Logger,
type LoggerConfig,
type Logger
createLogger
} from './common/logger/index.js';
/**
@@ -170,8 +170,8 @@ export class TmCore {
}
// Initialize domain facades
this._tasks = new TasksDomain(this._configManager);
this._auth = new AuthDomain();
this._tasks = new TasksDomain(this._configManager, this._auth);
this._workflow = new WorkflowDomain(this._configManager);
this._git = new GitDomain(this._projectPath);
this._config = new ConfigDomain(this._configManager);

View File

@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { Session } from '@supabase/supabase-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthManager } from '../../src/auth/auth-manager';
import { CredentialStore } from '../../src/auth/credential-store';
import type { AuthCredentials } from '../../src/auth/types';

View File

@@ -5,11 +5,11 @@
* when making API calls through AuthManager.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { Session } from '@supabase/supabase-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthManager } from '../../src/modules/auth/managers/auth-manager.js';
import { CredentialStore } from '../../src/modules/auth/services/credential-store.js';
import type { AuthCredentials } from '../../src/modules/auth/types.js';

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import path from 'path';
import fs from 'fs-extra';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
filterActivityLog,
logActivity,
readActivityLog,
filterActivityLog
readActivityLog
} from '../../../src/storage/activity-logger.js';
describe('Activity Logger', () => {

View File

@@ -2,10 +2,10 @@
* Tests for executor functionality
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ExecutorFactory,
ClaudeExecutor,
ExecutorFactory,
type ExecutorOptions
} from '../../src/executors/index.js';