mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: Hamster Integration - Cloud Storage, Sentry Telemetry, and Major CLI UX Improvements (#1452)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ export {
|
||||
generateSubtaskId,
|
||||
isValidTaskId,
|
||||
isValidSubtaskId,
|
||||
getParentTaskId
|
||||
getParentTaskId,
|
||||
normalizeDisplayId
|
||||
} from './id-generator.js';
|
||||
|
||||
// Export git utilities
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
112
packages/tm-core/src/modules/prompts/constants.ts
Normal file
112
packages/tm-core/src/modules/prompts/constants.ts
Normal 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';
|
||||
33
packages/tm-core/src/modules/prompts/index.ts
Normal file
33
packages/tm-core/src/modules/prompts/index.ts
Normal 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';
|
||||
210
packages/tm-core/src/modules/prompts/services/prompt-service.ts
Normal file
210
packages/tm-core/src/modules/prompts/services/prompt-service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
152
packages/tm-core/src/modules/prompts/types.ts
Normal file
152
packages/tm-core/src/modules/prompts/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user