feat: Hamster Integration - Cloud Storage, Sentry Telemetry, and Major CLI UX Improvements (#1452)

This commit is contained in:
Eyal Toledano
2025-11-30 06:35:49 -05:00
committed by GitHub
parent 9e934650b7
commit 4046b3ca44
352 changed files with 16204 additions and 3779 deletions

View File

@@ -9,7 +9,7 @@
* @param text - Raw text which may contain JSON
* @returns A valid JSON string if extraction succeeds, otherwise the original text
*/
import { parse, type ParseError } from 'jsonc-parser';
import { type ParseError, parse } from 'jsonc-parser';
export function extractJson(text: string): string {
let content = text.trim();

View File

@@ -1,8 +1,8 @@
import { ui } from '@tm/cli';
import type { TagInfo } from '@tm/core';
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';
@@ -15,6 +15,8 @@ export type { TagInfo };
export interface TagsBridgeParams extends BaseBridgeParams {
/** Whether to show metadata (default: false) */
showMetadata?: boolean;
/** Skip table display (when interactive selection will follow) */
skipTableDisplay?: boolean;
}
/**
@@ -41,7 +43,13 @@ export interface RemoteTagsResult {
export async function tryListTagsViaRemote(
params: TagsBridgeParams
): Promise<RemoteTagsResult | null> {
const { projectRoot, isMCP = false, outputFormat = 'text', report } = params;
const {
projectRoot,
isMCP = false,
outputFormat = 'text',
report,
skipTableDisplay = false
} = params;
// Check storage type using shared utility
const { isApiStorage, tmCore } = await checkStorageType(
@@ -69,7 +77,7 @@ export async function tryListTagsViaRemote(
return 0;
});
if (outputFormat === 'text' && !isMCP) {
if (outputFormat === 'text' && !isMCP && !skipTableDisplay) {
// Display results in a table format
if (tagsResult.tags.length === 0) {
console.log(

View File

@@ -1,5 +1,3 @@
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';
@@ -69,35 +67,10 @@ export async function tryUpdateViaRemote(
const mode = appendMode ? 'append' : 'update';
// Show CLI output if not MCP
if (!isMCP && outputFormat === 'text') {
const showDebug = process.env.TM_DEBUG === '1';
const promptPreview = showDebug
? `${prompt.substring(0, 60)}${prompt.length > 60 ? '...' : ''}`
: '[hidden]';
console.log(
boxen(
chalk.blue.bold(`Updating Task via Hamster`) +
'\n\n' +
chalk.white(`Task ID: ${taskId}`) +
'\n' +
chalk.white(`Mode: ${mode}`) +
'\n' +
chalk.white(`Prompt: ${promptPreview}`),
{
padding: 1,
borderColor: 'blue',
borderStyle: 'round',
margin: { top: 1, bottom: 1 }
}
)
);
}
// Show spinner for CLI users
const spinner =
!isMCP && outputFormat === 'text'
? ora({ text: 'Updating task on Hamster...', color: 'cyan' }).start()
? ora({ text: `Updating ${taskId} on Hamster...`, color: 'cyan' }).start()
: null;
try {
@@ -107,26 +80,7 @@ export async function tryUpdateViaRemote(
});
if (spinner) {
spinner.succeed('Task updated successfully');
}
if (outputFormat === 'text') {
console.log(
boxen(
chalk.green(`Successfully updated task ${taskId} via remote AI`) +
'\n\n' +
chalk.white('The task has been updated on the remote server.') +
'\n' +
chalk.white(
`Run ${chalk.yellow(`task-master show ${taskId}`)} to view the updated task.`
),
{
padding: 1,
borderColor: 'green',
borderStyle: 'round'
}
)
);
spinner.succeed('Task updated on Hamster');
}
// Return success result - signals that we handled it

View File

@@ -140,3 +140,72 @@ export function getParentTaskId(subtaskId: string): string | null {
const parts = subtaskId.split('.');
return parts[0];
}
/**
* Normalizes a display ID to the standard format (PREFIX-NUMBER)
* Handles various input formats:
* - "ham31" → "HAM-31"
* - "HAM31" → "HAM-31"
* - "ham-31" → "HAM-31"
* - "HAM-31" → "HAM-31" (already normalized)
* - "31" → "31" (plain number, no change)
* - "abc" → "abc" (no change if doesn't match pattern)
*
* @param id - The display ID to normalize
* @returns The normalized display ID
* @example
* ```typescript
* normalizeDisplayId("ham31"); // "HAM-31"
* normalizeDisplayId("HAM-31"); // "HAM-31"
* normalizeDisplayId("tas123"); // "TAS-123"
* normalizeDisplayId("123"); // "123"
* ```
*/
export function normalizeDisplayId(id: string): string {
if (!id) return id;
// Trim whitespace
const trimmed = id.trim();
// Pattern: 3 letters followed by numbers (no hyphen)
// e.g., "ham31", "HAM31", "tas123"
const noHyphenPattern = /^([a-zA-Z]{3})(\d+)$/;
const noHyphenMatch = trimmed.match(noHyphenPattern);
if (noHyphenMatch) {
const prefix = noHyphenMatch[1].toUpperCase();
const number = noHyphenMatch[2];
return `${prefix}-${number}`;
}
// Pattern: 3 letters, hyphen, numbers (already has hyphen, just normalize case)
// e.g., "ham-31", "HAM-31"
const withHyphenPattern = /^([a-zA-Z]{3})-(\d+)$/;
const withHyphenMatch = trimmed.match(withHyphenPattern);
if (withHyphenMatch) {
const prefix = withHyphenMatch[1].toUpperCase();
const number = withHyphenMatch[2];
return `${prefix}-${number}`;
}
// Also handle subtask format: ham31.1, HAM-31.1
const subtaskNoHyphenPattern = /^([a-zA-Z]{3})(\d+)\.(\d+)$/;
const subtaskNoHyphenMatch = trimmed.match(subtaskNoHyphenPattern);
if (subtaskNoHyphenMatch) {
const prefix = subtaskNoHyphenMatch[1].toUpperCase();
const taskNum = subtaskNoHyphenMatch[2];
const subtaskNum = subtaskNoHyphenMatch[3];
return `${prefix}-${taskNum}.${subtaskNum}`;
}
const subtaskWithHyphenPattern = /^([a-zA-Z]{3})-(\d+)\.(\d+)$/;
const subtaskWithHyphenMatch = trimmed.match(subtaskWithHyphenPattern);
if (subtaskWithHyphenMatch) {
const prefix = subtaskWithHyphenMatch[1].toUpperCase();
const taskNum = subtaskWithHyphenMatch[2];
const subtaskNum = subtaskWithHyphenMatch[3];
return `${prefix}-${taskNum}.${subtaskNum}`;
}
// No pattern matched, return as-is
return trimmed;
}

View File

@@ -10,7 +10,8 @@ export {
generateSubtaskId,
isValidTaskId,
isValidSubtaskId,
getParentTaskId
getParentTaskId,
normalizeDisplayId
} from './id-generator.js';
// Export git utilities

View File

@@ -2,10 +2,10 @@
* @fileoverview Tests for project root finder utilities
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
findProjectRoot,
normalizeProjectRoot

View File

@@ -3,11 +3,11 @@
* Provides functionality to locate project roots by searching for marker files/directories
*/
import path from 'node:path';
import fs from 'node:fs';
import path from 'node:path';
import {
TASKMASTER_PROJECT_MARKERS,
OTHER_PROJECT_MARKERS
OTHER_PROJECT_MARKERS,
TASKMASTER_PROJECT_MARKERS
} from '../constants/paths.js';
/**

View File

@@ -42,6 +42,9 @@ export type {
TagsWithStatsResult
} from './common/interfaces/storage.interface.js';
// Storage adapters - FileStorage for direct local file access
export { FileStorage } from './modules/storage/index.js';
// Constants
export * from './common/constants/index.js';
@@ -87,6 +90,8 @@ export { AuthenticationError } from './modules/auth/types.js';
// Auth constants
export {
AUTH_TIMEOUT_MS,
MFA_MAX_ATTEMPTS,
LOCAL_ONLY_COMMANDS,
type LocalOnlyCommand
} from './modules/auth/index.js';
@@ -116,7 +121,8 @@ export type { CommitMessageOptions } from './modules/git/services/commit-message
// Integration types
export type {
ExportTasksOptions,
ExportResult
ExportResult,
ImportTask
} from './modules/integration/services/export.service.js';
// Reports types
@@ -127,10 +133,26 @@ export type {
TaskComplexityData
} from './modules/reports/types.js';
// Prompts types
export type {
PromptAction,
PromptDisplayOptions,
PromptDisplayResult,
PromptMetrics,
PromptState,
PromptStateStore,
PromptType,
TriggerCondition,
TriggerEvaluationResult,
TriggerType,
UpgradePromptConfig
} from './modules/prompts/index.js';
// ========== Advanced API (for CLI/Extension/MCP) ==========
// Auth - Advanced
export { AuthManager } from './modules/auth/managers/auth-manager.js';
export { AuthDomain } from './modules/auth/auth-domain.js';
// Briefs - Advanced
export { BriefsDomain } from './modules/briefs/briefs-domain.js';
@@ -156,7 +178,32 @@ export {
} from './modules/tasks/services/task-file-generator.service.js';
// Integration - Advanced
export { ExportService } from './modules/integration/services/export.service.js';
export {
ExportService,
type GenerateBriefOptions,
type GenerateBriefResult,
type GenerateBriefFromPrdOptions,
type GenerateBriefFromPrdResult,
type BriefStatusResult,
type BriefStatusResponse,
type BriefGenerationProgress,
type SendTeamInvitationsResult,
type InvitationResult
} from './modules/integration/services/export.service.js';
// Prompts - Advanced
export { PromptService } from './modules/prompts/services/prompt-service.js';
export { PromptStateManager } from './modules/prompts/services/prompt-state-manager.js';
export {
TriggerEvaluator,
type TriggerContext
} from './modules/prompts/services/trigger-evaluator.js';
export {
DEFAULT_PROMPT_CONFIG,
DEFAULT_TRIGGER_CONDITIONS,
PROMPT_STATE_KEY,
PROMPT_STATE_VERSION
} from './modules/prompts/constants.js';
// ========== Testing Utilities ==========

View File

@@ -2,7 +2,7 @@
* Auth Domain tests
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthDomain } from './auth-domain.js';
describe('AuthDomain', () => {

View File

@@ -6,6 +6,7 @@
import path from 'node:path';
import type { StorageType } from '../../common/types/index.js';
import type { Brief } from '../briefs/types.js';
import { type AuthBlockResult, checkAuthBlock } from './command.guard.js';
import { AuthManager } from './managers/auth-manager.js';
import type {
Organization,
@@ -13,10 +14,10 @@ import type {
} from './services/organization.service.js';
import type {
AuthCredentials,
MFAVerificationResult,
OAuthFlowOptions,
UserContext
} from './types.js';
import { checkAuthBlock, type AuthBlockResult } from './command.guard.js';
/**
* Display information for storage context
@@ -107,6 +108,25 @@ export class AuthDomain {
return this.authManager.verifyMFA(factorId, code);
}
/**
* Verify MFA with retry support
* Allows multiple attempts with a callback for prompting the user
*
* @param factorId - MFA factor ID from the MFA_REQUIRED error
* @param codeProvider - Function that prompts for and returns the MFA code
* @param options - Optional configuration for retry behavior
*/
async verifyMFAWithRetry(
factorId: string,
codeProvider: () => Promise<string>,
options?: {
maxAttempts?: number;
onInvalidCode?: (attempt: number, remaining: number) => void;
}
): Promise<MFAVerificationResult> {
return this.authManager.verifyMFAWithRetry(factorId, codeProvider, options);
}
/**
* Get OAuth authorization URL
*/
@@ -274,9 +294,9 @@ export class AuthDomain {
/**
* Get web app base URL from environment configuration
* @private
* Handles protocol detection and localhost vs production domains
*/
private getWebAppUrl(): string | undefined {
getApiBaseUrl(): string | undefined {
const baseDomain =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
@@ -296,4 +316,11 @@ export class AuthDomain {
return `https://${baseDomain}`;
}
/**
* @deprecated Use getApiBaseUrl() instead
*/
private getWebAppUrl(): string | undefined {
return this.getApiBaseUrl();
}
}

View File

@@ -2,6 +2,17 @@
* @fileoverview Auth module constants
*/
/**
* Authentication timeout in milliseconds (10 minutes)
* This allows time for email confirmation during sign-up
*/
export const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Maximum number of MFA verification attempts
*/
export const MFA_MAX_ATTEMPTS = 3;
/**
* Commands that are only available for local file storage
* These commands are blocked when using Hamster (API storage)

View File

@@ -32,4 +32,9 @@ export {
export { isLocalOnlyCommand, type AuthBlockResult } from './command.guard.js';
// Auth constants
export { LOCAL_ONLY_COMMANDS, type LocalOnlyCommand } from './constants.js';
export {
AUTH_TIMEOUT_MS,
MFA_MAX_ATTEMPTS,
LOCAL_ONLY_COMMANDS,
type LocalOnlyCommand
} from './constants.js';

View File

@@ -85,9 +85,9 @@ vi.mock('../../integration/clients/supabase-client.js', () => {
};
});
import { AuthenticationError } from '../types.js';
// Import SUT after mocks
import { AuthManager } from './auth-manager.js';
import { AuthenticationError } from '../types.js';
describe('AuthManager Singleton', () => {
beforeEach(() => {

View File

@@ -2,10 +2,10 @@
* Tests for SessionManager
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the logger
const mockLogger = {
@@ -59,8 +59,8 @@ vi.mock('./context-store.js', () => ({
}
}));
import { SessionManager } from './session-manager.js';
import { AuthenticationError } from '../types.js';
import { SessionManager } from './session-manager.js';
describe('SessionManager', () => {
beforeEach(() => {

View File

@@ -8,9 +8,9 @@ import os from 'os';
import path from 'path';
import { getLogger } from '../../../common/logger/index.js';
import { SupabaseAuthClient } from '../../integration/clients/supabase-client.js';
import { ContextStore } from './context-store.js';
import type { AuthCredentials } from '../types.js';
import { AuthenticationError } from '../types.js';
import { ContextStore } from './context-store.js';
/**
* SessionManager - Focused service for session and token management

View File

@@ -3,10 +3,10 @@
* Verifies session persistence with steno atomic writes
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fsSync from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SupabaseSessionStorage } from './supabase-session-storage.js';
describe('SupabaseSessionStorage', () => {

View File

@@ -14,11 +14,11 @@
* - Persistence to ~/.taskmaster/session.json with atomic writes via steno
*/
import fs from 'fs/promises';
import fsSync from 'fs';
import path from 'path';
import { Writer } from 'steno';
import type { SupportedStorage } from '@supabase/supabase-js';
import fs from 'fs/promises';
import { Writer } from 'steno';
import { getLogger } from '../../../common/logger/index.js';
const DEFAULT_SESSION_FILE = path.join(

View File

@@ -2,8 +2,8 @@
* Tests for SupabaseAuthClient
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Session } from '@supabase/supabase-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock logger
const mockLogger = {
@@ -29,9 +29,9 @@ vi.mock('../../auth/services/supabase-session-storage.js', () => ({
}
}));
import { AuthenticationError } from '../../auth/types.js';
// Import after mocking (synchronous imports)
import { SupabaseAuthClient } from './supabase-client.js';
import { AuthenticationError } from '../../auth/types.js';
describe('SupabaseAuthClient', () => {
let authClient: SupabaseAuthClient;

View File

@@ -7,8 +7,14 @@ 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 {
BriefStatusResult,
ExportResult,
ExportTasksOptions
ExportTasksOptions,
GenerateBriefFromPrdOptions,
GenerateBriefFromPrdResult,
GenerateBriefOptions,
GenerateBriefResult,
SendTeamInvitationsResult
} from './services/export.service.js';
/**
@@ -26,9 +32,64 @@ export class IntegrationDomain {
// ========== Export Operations ==========
/**
* Export tasks to external systems (e.g., Hamster briefs)
* Generate a new brief from local tasks
* This is the primary export method - creates a brief and imports all tasks atomically
*
* @param options - Options for generating the brief
* @returns Result with created brief details and task mapping
*/
async generateBriefFromTasks(
options?: GenerateBriefOptions
): Promise<GenerateBriefResult> {
return this.exportService.generateBriefFromTasks(options);
}
/**
* Export tasks to an existing brief
* @deprecated Use generateBriefFromTasks instead - briefs are now created from tasks
*/
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
return this.exportService.exportTasks(options);
}
/**
* Generate a new brief from PRD content
* Sends PRD to Hamster which creates a brief and generates tasks asynchronously
*
* @param options - Options including the PRD content
* @returns Result with created brief details (tasks are generated async)
*/
async generateBriefFromPrd(
options: GenerateBriefFromPrdOptions
): Promise<GenerateBriefFromPrdResult> {
return this.exportService.generateBriefFromPrd(options);
}
/**
* Get the current status of a brief's task generation
* Used to poll progress after generateBriefFromPrd
*
* @param briefId - The brief ID to check
* @returns Status result with progress details
*/
async getBriefStatus(briefId: string): Promise<BriefStatusResult> {
return this.exportService.getBriefStatus(briefId);
}
/**
* Send team invitations to collaborate on Hamster
* This is called separately from brief creation
*
* @param accountSlug - The organization slug (from brief URL)
* @param emails - Array of email addresses to invite
* @param role - Role for invited users (default: 'member')
* @returns Result with invitation statuses
*/
async sendTeamInvitations(
accountSlug: string,
emails: string[],
role: 'member' | 'admin' = 'member'
): Promise<SendTeamInvitationsResult> {
return this.exportService.sendTeamInvitations(accountSlug, emails, role);
}
}

View File

@@ -0,0 +1,112 @@
/**
* @fileoverview Constants for the upgrade prompt system
* Defines default trigger conditions and messaging
*/
import type { TriggerCondition, UpgradePromptConfig } from './types.js';
/**
* Default trigger conditions for upgrade prompts
* Ordered by priority (higher priority shown first)
*/
export const DEFAULT_TRIGGER_CONDITIONS: TriggerCondition[] = [
{
type: 'task_count',
threshold: 10,
message:
'Your tasks are growing! Upgrade to Hamster Studio (Multiplayer) for coordinated team action, AI context sharing, and faster shipping.',
promptType: 'upgrade_suggestion',
showOnce: false,
cooldownDays: 7,
priority: 80
},
{
type: 'tags_used',
threshold: 3,
message:
'Organize by tags? Hamster briefs let you group and collaborate on tagged tasks with your team.',
promptType: 'educational_notice',
showOnce: false,
cooldownDays: 14,
priority: 70
},
{
type: 'list_count',
threshold: 5,
message:
'Managing multiple projects? Create Hamster briefs to organize work across your team.',
promptType: 'educational_notice',
showOnce: false,
cooldownDays: 14,
priority: 50
},
{
type: 'dependencies_complex',
threshold: 5,
message:
'Your tasks have complex dependencies. Hamster visualizes these relationships and tracks blockers automatically.',
promptType: 'educational_notice',
showOnce: false,
cooldownDays: 14,
priority: 60
},
{
type: 'days_active',
threshold: 7,
message:
'Ready to collaborate? Export your tasks to Hamster Studio and start shipping faster with your team.',
promptType: 'upgrade_suggestion',
showOnce: true,
priority: 90
},
{
type: 'export_attempt',
threshold: 1,
message:
'Export to Hamster Studio to enable coordinated team action, AI context sharing, and alignment in hours.',
promptType: 'critical_choice',
showOnce: false,
cooldownDays: 1,
priority: 100
},
{
type: 'no_connection',
threshold: 1,
message:
'Connect to Hamster Studio to sync your tasks across devices and collaborate with your team.',
promptType: 'upgrade_suggestion',
showOnce: false,
cooldownDays: 3,
priority: 75
},
{
type: 'parse_prd',
threshold: 1,
message:
'Export your PRD to Hamster for dynamic task generation and team collaboration.',
promptType: 'critical_choice',
showOnce: false,
cooldownDays: 1,
priority: 95
}
];
/**
* Default configuration for upgrade prompts
*/
export const DEFAULT_PROMPT_CONFIG: UpgradePromptConfig = {
enabled: true,
triggers: DEFAULT_TRIGGER_CONDITIONS,
defaultCooldownDays: 7,
respectDismissed: true
};
/**
* Prompt state storage version
*/
export const PROMPT_STATE_VERSION = '1.0.0';
/**
* Key for storing prompt state in config.custom
*/
export const PROMPT_STATE_KEY = 'upgradePrompts';

View File

@@ -0,0 +1,33 @@
/**
* @fileoverview Prompts module exports
* Context-aware upgrade prompt system for Task Master
*/
// Types
export type {
PromptAction,
PromptDisplayOptions,
PromptDisplayResult,
PromptMetrics,
PromptState,
PromptStateStore,
PromptType,
TriggerCondition,
TriggerEvaluationResult,
TriggerType,
UpgradePromptConfig
} from './types.js';
// Constants
export {
DEFAULT_PROMPT_CONFIG,
DEFAULT_TRIGGER_CONDITIONS,
PROMPT_STATE_KEY,
PROMPT_STATE_VERSION
} from './constants.js';
// Services
export { PromptService } from './services/prompt-service.js';
export { PromptStateManager } from './services/prompt-state-manager.js';
export { TriggerEvaluator } from './services/trigger-evaluator.js';
export type { TriggerContext } from './services/trigger-evaluator.js';

View File

@@ -0,0 +1,210 @@
/**
* @fileoverview Prompt Service
* Main service for managing upgrade prompts with context-aware trigger detection
*/
import { getLogger } from '../../../common/logger/index.js';
import type {
PromptAction,
PromptMetrics,
TriggerEvaluationResult,
TriggerType,
UpgradePromptConfig
} from '../types.js';
import { PromptStateManager } from './prompt-state-manager.js';
import { TriggerContext, TriggerEvaluator } from './trigger-evaluator.js';
/**
* Main service for managing upgrade prompts
*/
export class PromptService {
private readonly logger = getLogger('PromptService');
private readonly stateManager: PromptStateManager;
private readonly evaluator: TriggerEvaluator;
constructor(projectRoot: string, config?: UpgradePromptConfig) {
this.stateManager = new PromptStateManager(projectRoot);
this.evaluator = new TriggerEvaluator(this.stateManager, config);
}
/**
* Evaluate and get the prompt to display (if any)
* Returns the highest priority trigger that should show
*/
async evaluatePrompts(
context: TriggerContext = {}
): Promise<TriggerEvaluationResult> {
try {
return await this.evaluator.evaluate(context);
} catch (error) {
this.logger.error('Error evaluating prompts:', error);
return {
shouldShow: false,
reason: `Evaluation error: ${(error as Error).message}`
};
}
}
/**
* Evaluate a specific trigger type
*/
async evaluateTrigger(
triggerType: TriggerType,
context: TriggerContext = {}
): Promise<TriggerEvaluationResult> {
try {
return await this.evaluator.evaluateTriggerType(triggerType, context);
} catch (error) {
this.logger.error(`Error evaluating trigger ${triggerType}:`, error);
return {
shouldShow: false,
reason: `Evaluation error: ${(error as Error).message}`
};
}
}
/**
* Record that a prompt was shown
* Call this after displaying a prompt to the user
*/
async recordPromptShown(triggerType: TriggerType): Promise<void> {
try {
await this.stateManager.recordPromptShown(triggerType);
this.logger.debug(`Recorded prompt shown: ${triggerType}`);
} catch (error) {
this.logger.error(`Error recording prompt shown:`, error);
}
}
/**
* Record user action on a prompt
*/
async recordAction(
triggerType: TriggerType,
action: PromptAction
): Promise<void> {
try {
await this.stateManager.recordPromptAction(triggerType, action);
this.logger.debug(`Recorded prompt action: ${triggerType} -> ${action}`);
} catch (error) {
this.logger.error(`Error recording prompt action:`, error);
}
}
/**
* Update metrics based on user activity
*/
async updateMetrics(updates: Partial<PromptMetrics>): Promise<void> {
try {
await this.stateManager.updateMetrics(updates);
} catch (error) {
this.logger.error('Error updating metrics:', error);
}
}
/**
* Increment a specific metric
*/
async incrementMetric(
metric: keyof Pick<
PromptMetrics,
| 'totalTaskCount'
| 'tagCount'
| 'listCommandCount'
| 'tasksWithDependencies'
>,
amount: number = 1
): Promise<void> {
try {
await this.stateManager.incrementMetric(metric, amount);
} catch (error) {
this.logger.error(`Error incrementing metric ${metric}:`, error);
}
}
/**
* Get current metrics
*/
async getMetrics(): Promise<PromptMetrics> {
return this.stateManager.getMetrics();
}
/**
* Dismiss a prompt permanently
*/
async dismissPrompt(triggerType: TriggerType): Promise<void> {
await this.recordAction(triggerType, 'dismissed');
}
/**
* Check if prompts are enabled
*/
isEnabled(): boolean {
return this.evaluator.isEnabled();
}
/**
* Get the message for a specific trigger type
*/
getPromptMessage(triggerType: TriggerType): string | null {
const trigger = this.evaluator.getTrigger(triggerType);
return trigger?.message || null;
}
/**
* Reset all prompt state
*/
async reset(): Promise<void> {
await this.stateManager.reset();
}
/**
* Reset a specific trigger's state
*/
async resetTrigger(triggerType: TriggerType): Promise<void> {
await this.stateManager.resetTrigger(triggerType);
}
/**
* Sync metrics with actual task data
* Call this periodically to ensure metrics are accurate
*/
async syncMetrics(data: {
taskCount?: number;
tagCount?: number;
tasksWithDependencies?: number;
}): Promise<void> {
const updates: Partial<PromptMetrics> = {};
if (data.taskCount !== undefined) {
updates.totalTaskCount = data.taskCount;
}
if (data.tagCount !== undefined) {
updates.tagCount = data.tagCount;
}
if (data.tasksWithDependencies !== undefined) {
updates.tasksWithDependencies = data.tasksWithDependencies;
}
if (Object.keys(updates).length > 0) {
await this.updateMetrics(updates);
}
}
/**
* Helper to build trigger context from common parameters
*/
static buildContext(params: {
command?: string;
isAuthenticated?: boolean;
hasBriefConnected?: boolean;
custom?: Record<string, unknown>;
}): TriggerContext {
return {
currentCommand: params.command,
isAuthenticated: params.isAuthenticated,
hasBriefConnected: params.hasBriefConnected,
custom: params.custom
};
}
}

View File

@@ -0,0 +1,284 @@
/**
* @fileoverview Prompt State Manager
* Manages persistence and state tracking for upgrade prompts
* Uses the existing RuntimeStateManager's metadata field
*/
import { getLogger } from '../../../common/logger/index.js';
import { RuntimeStateManager } from '../../config/services/runtime-state-manager.service.js';
import { PROMPT_STATE_VERSION } from '../constants.js';
import type {
PromptAction,
PromptMetrics,
PromptState,
PromptStateStore,
TriggerType
} from '../types.js';
/**
* Key used for storing prompt state in RuntimeStateManager metadata
*/
const PROMPTS_METADATA_KEY = 'upgradePrompts';
/**
* Manages prompt state using the existing RuntimeStateManager
*/
export class PromptStateManager {
private readonly logger = getLogger('PromptStateManager');
private readonly runtimeStateManager: RuntimeStateManager;
private cachedState: PromptStateStore | null = null;
constructor(projectRoot: string) {
this.runtimeStateManager = new RuntimeStateManager(projectRoot);
}
/**
* Get the current prompt state
*/
async getState(): Promise<PromptStateStore> {
if (this.cachedState) {
return this.cachedState;
}
this.cachedState = await this.loadState();
return this.cachedState;
}
/**
* Load state from RuntimeStateManager metadata
*/
private async loadState(): Promise<PromptStateStore> {
try {
await this.runtimeStateManager.loadState();
const runtimeState = this.runtimeStateManager.getState();
const promptsData = runtimeState.metadata?.[PROMPTS_METADATA_KEY];
if (promptsData && typeof promptsData === 'object') {
return this.validateAndMigrate(promptsData);
}
} catch (error) {
this.logger.warn('Failed to load prompt state, using defaults:', error);
}
return this.createDefaultState();
}
/**
* Create default state
*/
private createDefaultState(): PromptStateStore {
return {
triggers: {},
metrics: {
totalTaskCount: 0,
tagCount: 0,
listCommandCount: 0,
tasksWithDependencies: 0
},
lastUpdated: new Date().toISOString(),
version: PROMPT_STATE_VERSION
};
}
/**
* Validate and migrate state if needed
*/
private validateAndMigrate(data: any): PromptStateStore {
const state: PromptStateStore = {
triggers: data.triggers || {},
metrics: {
totalTaskCount: data.metrics?.totalTaskCount || 0,
tagCount: data.metrics?.tagCount || 0,
listCommandCount: data.metrics?.listCommandCount || 0,
tasksWithDependencies: data.metrics?.tasksWithDependencies || 0,
firstActivityAt: data.metrics?.firstActivityAt,
lastActivityAt: data.metrics?.lastActivityAt
},
lastUpdated: data.lastUpdated || new Date().toISOString(),
version: PROMPT_STATE_VERSION
};
return state;
}
/**
* Save state to RuntimeStateManager metadata
*/
async saveState(): Promise<void> {
if (!this.cachedState) {
return;
}
try {
this.cachedState.lastUpdated = new Date().toISOString();
await this.runtimeStateManager.updateMetadata({
[PROMPTS_METADATA_KEY]: this.cachedState
});
} catch (error) {
this.logger.error('Failed to save prompt state:', error);
throw error;
}
}
/**
* Get state for a specific trigger
*/
async getTriggerState(triggerType: TriggerType): Promise<PromptState | null> {
const state = await this.getState();
return state.triggers[triggerType] || null;
}
/**
* Record that a prompt was shown
*/
async recordPromptShown(triggerType: TriggerType): Promise<void> {
const state = await this.getState();
const now = new Date().toISOString();
const existing = state.triggers[triggerType];
state.triggers[triggerType] = {
firstShownAt: existing?.firstShownAt || now,
lastShownAt: now,
showCount: (existing?.showCount || 0) + 1,
dismissed: existing?.dismissed || false
};
await this.saveState();
}
/**
* Record user action on a prompt
*/
async recordPromptAction(
triggerType: TriggerType,
action: PromptAction
): Promise<void> {
const state = await this.getState();
const now = new Date().toISOString();
const existing = state.triggers[triggerType] || {
showCount: 1,
dismissed: false
};
state.triggers[triggerType] = {
...existing,
action,
actionAt: now,
dismissed: action === 'dismissed'
};
await this.saveState();
}
/**
* Update metrics
*/
async updateMetrics(updates: Partial<PromptMetrics>): Promise<void> {
const state = await this.getState();
const now = new Date().toISOString();
// Set first activity if not set
if (!state.metrics.firstActivityAt) {
state.metrics.firstActivityAt = now;
}
state.metrics.lastActivityAt = now;
// Apply updates
Object.assign(state.metrics, updates);
await this.saveState();
}
/**
* Increment a specific metric
*/
async incrementMetric(
metric: keyof Pick<
PromptMetrics,
| 'totalTaskCount'
| 'tagCount'
| 'listCommandCount'
| 'tasksWithDependencies'
>,
amount: number = 1
): Promise<void> {
const state = await this.getState();
state.metrics[metric] = (state.metrics[metric] || 0) + amount;
if (!state.metrics.firstActivityAt) {
state.metrics.firstActivityAt = new Date().toISOString();
}
state.metrics.lastActivityAt = new Date().toISOString();
await this.saveState();
}
/**
* Get current metrics
*/
async getMetrics(): Promise<PromptMetrics> {
const state = await this.getState();
return state.metrics;
}
/**
* Check if prompt is within cooldown period
*/
async isWithinCooldown(
triggerType: TriggerType,
cooldownDays: number
): Promise<boolean> {
const triggerState = await this.getTriggerState(triggerType);
if (!triggerState?.lastShownAt) {
return false;
}
const lastShown = new Date(triggerState.lastShownAt);
const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
const now = Date.now();
return now - lastShown.getTime() < cooldownMs;
}
/**
* Check if prompt has been dismissed
*/
async isDismissed(triggerType: TriggerType): Promise<boolean> {
const triggerState = await this.getTriggerState(triggerType);
return triggerState?.dismissed || false;
}
/**
* Get days since first activity
*/
async getDaysActive(): Promise<number> {
const state = await this.getState();
if (!state.metrics.firstActivityAt) {
return 0;
}
const firstActivity = new Date(state.metrics.firstActivityAt);
const now = Date.now();
const dayMs = 24 * 60 * 60 * 1000;
return Math.floor((now - firstActivity.getTime()) / dayMs);
}
/**
* Reset all prompt state
*/
async reset(): Promise<void> {
this.cachedState = this.createDefaultState();
await this.saveState();
}
/**
* Reset a specific trigger's state
*/
async resetTrigger(triggerType: TriggerType): Promise<void> {
const state = await this.getState();
delete state.triggers[triggerType];
await this.saveState();
}
}

View File

@@ -0,0 +1,294 @@
/**
* @fileoverview Trigger Evaluator
* Evaluates trigger conditions to determine if prompts should be shown
*/
import { DEFAULT_TRIGGER_CONDITIONS } from '../constants.js';
import type {
PromptMetrics,
TriggerCondition,
TriggerEvaluationResult,
TriggerType,
UpgradePromptConfig
} from '../types.js';
import type { PromptStateManager } from './prompt-state-manager.js';
/**
* Context for evaluating triggers
*/
export interface TriggerContext {
/** Current command being executed */
currentCommand?: string;
/** Whether user is authenticated with Hamster */
isAuthenticated?: boolean;
/** Whether user has a brief connected */
hasBriefConnected?: boolean;
/** Custom context data */
custom?: Record<string, unknown>;
}
/**
* Evaluates trigger conditions and determines if prompts should be shown
*/
export class TriggerEvaluator {
private readonly stateManager: PromptStateManager;
private readonly config: UpgradePromptConfig;
constructor(stateManager: PromptStateManager, config?: UpgradePromptConfig) {
this.stateManager = stateManager;
this.config = config || {
enabled: true,
triggers: DEFAULT_TRIGGER_CONDITIONS,
defaultCooldownDays: 7,
respectDismissed: true
};
}
/**
* Evaluate all triggers and return the highest priority one that should show
*/
async evaluate(
context: TriggerContext = {}
): Promise<TriggerEvaluationResult> {
if (!this.config.enabled) {
return {
shouldShow: false,
reason: 'Prompts are disabled'
};
}
const metrics = await this.stateManager.getMetrics();
const daysActive = await this.stateManager.getDaysActive();
// Sort triggers by priority (highest first)
const sortedTriggers = [...this.config.triggers].sort(
(a, b) => b.priority - a.priority
);
for (const trigger of sortedTriggers) {
const result = await this.evaluateTrigger(
trigger,
metrics,
daysActive,
context
);
if (result.shouldShow) {
return result;
}
}
return {
shouldShow: false,
reason: 'No trigger conditions met'
};
}
/**
* Evaluate a specific trigger type
*/
async evaluateTriggerType(
triggerType: TriggerType,
context: TriggerContext = {}
): Promise<TriggerEvaluationResult> {
const trigger = this.config.triggers.find((t) => t.type === triggerType);
if (!trigger) {
return {
shouldShow: false,
reason: `Unknown trigger type: ${triggerType}`
};
}
const metrics = await this.stateManager.getMetrics();
const daysActive = await this.stateManager.getDaysActive();
return this.evaluateTrigger(trigger, metrics, daysActive, context);
}
/**
* Evaluate a single trigger condition
*/
private async evaluateTrigger(
trigger: TriggerCondition,
metrics: PromptMetrics,
daysActive: number,
context: TriggerContext
): Promise<TriggerEvaluationResult> {
// Check if trigger is dismissed
if (this.config.respectDismissed) {
const isDismissed = await this.stateManager.isDismissed(trigger.type);
if (isDismissed) {
return {
shouldShow: false,
trigger,
reason: 'Prompt was dismissed by user'
};
}
}
// Check cooldown
const cooldownDays =
trigger.cooldownDays ?? this.config.defaultCooldownDays;
const isInCooldown = await this.stateManager.isWithinCooldown(
trigger.type,
cooldownDays
);
if (isInCooldown) {
return {
shouldShow: false,
trigger,
reason: `Within cooldown period (${cooldownDays} days)`
};
}
// Check showOnce
if (trigger.showOnce) {
const triggerState = await this.stateManager.getTriggerState(
trigger.type
);
if (triggerState && triggerState.showCount > 0) {
return {
shouldShow: false,
trigger,
reason: 'Prompt already shown (showOnce=true)'
};
}
}
// Evaluate the threshold condition
const meetsThreshold = this.evaluateThreshold(
trigger,
metrics,
daysActive,
context
);
if (!meetsThreshold.met) {
return {
shouldShow: false,
trigger,
reason: meetsThreshold.reason
};
}
return {
shouldShow: true,
trigger,
reason: meetsThreshold.reason
};
}
/**
* Evaluate if a trigger's threshold is met
*/
private evaluateThreshold(
trigger: TriggerCondition,
metrics: PromptMetrics,
daysActive: number,
context: TriggerContext
): { met: boolean; reason: string } {
switch (trigger.type) {
case 'task_count':
const taskMet = metrics.totalTaskCount >= trigger.threshold;
return {
met: taskMet,
reason: taskMet
? `Task count ${metrics.totalTaskCount} >= ${trigger.threshold}`
: `Task count ${metrics.totalTaskCount} < ${trigger.threshold}`
};
case 'tags_used':
const tagMet = metrics.tagCount >= trigger.threshold;
return {
met: tagMet,
reason: tagMet
? `Tag count ${metrics.tagCount} >= ${trigger.threshold}`
: `Tag count ${metrics.tagCount} < ${trigger.threshold}`
};
case 'list_count':
const listMet = metrics.listCommandCount >= trigger.threshold;
return {
met: listMet,
reason: listMet
? `List count ${metrics.listCommandCount} >= ${trigger.threshold}`
: `List count ${metrics.listCommandCount} < ${trigger.threshold}`
};
case 'dependencies_complex':
const depMet = metrics.tasksWithDependencies >= trigger.threshold;
return {
met: depMet,
reason: depMet
? `Tasks with dependencies ${metrics.tasksWithDependencies} >= ${trigger.threshold}`
: `Tasks with dependencies ${metrics.tasksWithDependencies} < ${trigger.threshold}`
};
case 'days_active':
const daysMet = daysActive >= trigger.threshold;
return {
met: daysMet,
reason: daysMet
? `Days active ${daysActive} >= ${trigger.threshold}`
: `Days active ${daysActive} < ${trigger.threshold}`
};
case 'export_attempt':
// Only show during export command
const isExport = context.currentCommand === 'export';
return {
met: isExport,
reason: isExport
? 'User is attempting export'
: 'Not an export command'
};
case 'no_connection':
// Show when not authenticated or no brief connected
const notConnected =
!context.isAuthenticated || !context.hasBriefConnected;
return {
met: notConnected,
reason: notConnected
? 'No Hamster connection detected'
: 'User is connected to Hamster'
};
case 'parse_prd':
// Only show during parse-prd command
const isParsePrd = context.currentCommand === 'parse-prd';
return {
met: isParsePrd,
reason: isParsePrd
? 'User is parsing a PRD'
: 'Not a parse-prd command'
};
default:
return {
met: false,
reason: `Unknown trigger type: ${trigger.type}`
};
}
}
/**
* Get all available triggers
*/
getTriggers(): TriggerCondition[] {
return this.config.triggers;
}
/**
* Get trigger by type
*/
getTrigger(type: TriggerType): TriggerCondition | undefined {
return this.config.triggers.find((t) => t.type === type);
}
/**
* Check if prompts are enabled
*/
isEnabled(): boolean {
return this.config.enabled;
}
}

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview Types for the upgrade prompt system
* Defines interfaces for prompts, triggers, and state management
*/
/**
* Types of upgrade prompts
*/
export type PromptType =
| 'upgrade_suggestion' // Non-blocking educational notice
| 'critical_choice' // Inquirer-based choice prompt
| 'educational_notice'; // Quick tip/notice
/**
* Prompt trigger types - key moments when prompts should be shown
*/
export type TriggerType =
| 'task_count' // User has created N tasks
| 'tags_used' // User is using tag organization
| 'list_count' // User has run tm list N times
| 'dependencies_complex' // Complex dependencies detected
| 'days_active' // User has been active for N days
| 'export_attempt' // User attempts to export
| 'no_connection' // No external service connected
| 'parse_prd'; // User parsing a PRD
/**
* User actions on prompts
*/
export type PromptAction = 'shown' | 'dismissed' | 'accepted' | 'learn_more';
/**
* Individual prompt state record
*/
export interface PromptState {
/** When the prompt was first shown */
firstShownAt?: string;
/** When the prompt was last shown */
lastShownAt?: string;
/** Total number of times shown */
showCount: number;
/** User action taken */
action?: PromptAction;
/** When the action was taken */
actionAt?: string;
/** Whether prompt is permanently dismissed */
dismissed: boolean;
}
/**
* Complete prompt state storage
*/
export interface PromptStateStore {
/** State for each trigger type */
triggers: Partial<Record<TriggerType, PromptState>>;
/** Metrics for trigger evaluation */
metrics: PromptMetrics;
/** When state was last updated */
lastUpdated: string;
/** Version for future migrations */
version: string;
}
/**
* Metrics used to evaluate triggers
*/
export interface PromptMetrics {
/** Total task count (across all tags) */
totalTaskCount: number;
/** Number of unique tags created */
tagCount: number;
/** Number of times tm list has been run */
listCommandCount: number;
/** Number of tasks with dependencies */
tasksWithDependencies: number;
/** First activity timestamp */
firstActivityAt?: string;
/** Last activity timestamp */
lastActivityAt?: string;
}
/**
* Trigger condition configuration
*/
export interface TriggerCondition {
/** Type of trigger */
type: TriggerType;
/** Threshold value that triggers the prompt */
threshold: number;
/** Prompt message to display */
message: string;
/** Prompt type */
promptType: PromptType;
/** Whether prompt should only be shown once */
showOnce: boolean;
/** Cooldown period in days before showing again */
cooldownDays?: number;
/** Priority for prompt display (higher = more important) */
priority: number;
}
/**
* Result of trigger evaluation
*/
export interface TriggerEvaluationResult {
/** Whether the trigger condition is met */
shouldShow: boolean;
/** The trigger that matched */
trigger?: TriggerCondition;
/** Reason for decision */
reason: string;
}
/**
* Prompt display options
*/
export interface PromptDisplayOptions {
/** Force display even if already shown */
force?: boolean;
/** Custom message override */
customMessage?: string;
/** Context for the prompt */
context?: Record<string, unknown>;
}
/**
* Prompt display result
*/
export interface PromptDisplayResult {
/** Whether prompt was displayed */
displayed: boolean;
/** Trigger type that was displayed */
triggerType?: TriggerType;
/** User action taken */
action?: PromptAction;
/** Any error that occurred */
error?: string;
}
/**
* Configuration for upgrade prompts
*/
export interface UpgradePromptConfig {
/** Whether prompts are enabled globally */
enabled: boolean;
/** Trigger conditions */
triggers: TriggerCondition[];
/** Default cooldown in days */
defaultCooldownDays: number;
/** Whether to respect dismissed state */
respectDismissed: boolean;
}

View File

@@ -3,6 +3,10 @@
*/
import path from 'node:path';
import {
ERROR_CODES,
TaskMasterError
} from '../../../../common/errors/task-master-error.js';
import type {
IStorage,
LoadTasksOptions,
@@ -14,10 +18,6 @@ import type {
TaskMetadata,
TaskStatus
} from '../../../../common/types/index.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';

View File

@@ -24,7 +24,7 @@
* ```
*/
import type { Task, Subtask, TaskMetadata } from '../common/types/index.js';
import type { Subtask, Task, TaskMetadata } from '../common/types/index.js';
/**
* File structure for tasks.json

View File

@@ -22,8 +22,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MockSupabaseSessionStorage,
createMockLogger,
createApiStorageConfig
createApiStorageConfig,
createMockLogger
} from '../../../src/testing/index.js';
// Mock logger using shared mock factory
@@ -40,9 +40,9 @@ vi.mock(
})
);
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
// Import after mocking
import { SupabaseAuthClient } from '../../../src/modules/integration/clients/supabase-client.js';
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
import { StorageFactory } from '../../../src/modules/storage/services/storage-factory.js';
describe('Token Refresh - Singleton Integration', () => {

View File

@@ -10,13 +10,13 @@
* when multiple SupabaseAuthClient instances each tried to refresh the same token.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthError } from '@supabase/supabase-js';
import type { Session, User } from '@supabase/supabase-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MockSupabaseSessionStorage,
createMockLogger,
createApiStorageConfig
createApiStorageConfig,
createMockLogger
} from '../../../src/testing/index.js';
// Mock logger using shared mock factory
@@ -32,9 +32,9 @@ vi.mock(
})
);
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
// Import after mocking
import { SupabaseAuthClient } from '../../../src/modules/integration/clients/supabase-client.js';
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
import { StorageFactory } from '../../../src/modules/storage/services/storage-factory.js';
// Helper to create a session that expires at a specific time

View File

@@ -25,8 +25,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MockSupabaseSessionStorageMinimal,
createMockLogger,
createApiStorageConfig
createApiStorageConfig,
createMockLogger
} from '../../../src/testing/index.js';
// Mock logger using shared mock factory
@@ -43,9 +43,9 @@ vi.mock(
})
);
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
// Import after mocking
import { SupabaseAuthClient } from '../../../src/modules/integration/clients/supabase-client.js';
import { AuthManager } from '../../../src/modules/auth/managers/auth-manager.js';
import { StorageFactory } from '../../../src/modules/storage/services/storage-factory.js';
describe('SupabaseAuthClient - Singleton Pattern Validation', () => {