feat(core,cli): surface rich AI implementation metadata for remote tasks (#1521)

This commit is contained in:
Ralph Khreish
2025-12-16 13:00:22 +01:00
committed by GitHub
parent 8cd9dc09d5
commit 353e3bffd6
11 changed files with 1823 additions and 93 deletions

View File

@@ -0,0 +1,19 @@
---
"task-master-ai": minor
---
Enhanced task metadata display for remote/team mode tasks
- Tasks now display rich implementation guidance in team mode including:
- **Relevant Files**: Files to create, modify, or reference with descriptions
- **Codebase Patterns**: Coding patterns and conventions to follow
- **Existing Infrastructure**: Code and utilities to leverage
- **Scope Boundaries**: What's in and out of scope for the task
- **Implementation Approach**: Step-by-step guidance
- **Technical Constraints**: Requirements and limitations
- **Acceptance Criteria**: Definition of done checklist
- **Skills & Category**: Task classification and required expertise
- How to see the new task details:
1. Create a brief on tryhamster.com
2. Generate the plan of the brief
3. View subtasks

View File

@@ -6,8 +6,8 @@
import type { Task } from '@tm/core'; import type { Task } from '@tm/core';
import boxen from 'boxen'; import boxen from 'boxen';
import chalk from 'chalk'; import chalk from 'chalk';
import { renderContent } from '../../utils/content-renderer.js';
import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js'; import { getBoxWidth, getComplexityWithColor } from '../../utils/ui.js';
import { renderContent } from './task-detail.component.js';
/** /**
* Next task display options * Next task display options

View File

@@ -3,88 +3,68 @@
* Displays detailed task information in a structured format * Displays detailed task information in a structured format
*/ */
import type { StorageType, Subtask, Task } from '@tm/core'; import type {
ExistingInfrastructure,
RelevantFile,
ScopeBoundaries,
StorageType,
Subtask,
Task,
TaskCategory
} from '@tm/core';
import boxen from 'boxen'; import boxen from 'boxen';
import chalk from 'chalk'; import chalk from 'chalk';
import Table from 'cli-table3'; import Table from 'cli-table3';
import { MarkedExtension, marked } from 'marked'; import { renderContent } from '../../utils/content-renderer.js';
import { markedTerminal } from 'marked-terminal';
import TurndownService from 'turndown';
import { import {
getComplexityWithColor, getComplexityWithColor,
getPriorityWithColor, getPriorityWithColor,
getStatusWithColor getStatusWithColor
} from '../../utils/ui.js'; } from '../../utils/ui.js';
// Initialize turndown for HTML to Markdown conversion // ============================================================================
const turndownService = new TurndownService({ // Constants and Helper Functions
headingStyle: 'atx', // ============================================================================
codeBlockStyle: 'fenced',
bulletListMarker: '-'
});
/** /**
* Convert HTML content to Markdown, then render for terminal * Icons for task categories
* Handles tiptap HTML from Hamster gracefully
*/ */
export function renderContent(content: string): string { const CATEGORY_ICONS: Record<TaskCategory, string> = {
if (!content) return ''; research: '🔍',
design: '🎨',
development: '🔧',
testing: '🧪',
documentation: '📝',
review: '👀'
};
// Clean up escape characters first - order matters: handle escaped backslashes first /**
let cleaned = content * Get icon for file action
.replace(/\\\\/g, '\\') */
.replace(/\\n/g, '\n') function getFileActionIcon(action: RelevantFile['action']): string {
.replace(/\\t/g, '\t') switch (action) {
.replace(/\\"/g, '"'); case 'create':
return chalk.green('✚ CREATE');
// Check if content has HTML tags - if so, convert to markdown first case 'modify':
if (/<[^>]+>/.test(cleaned)) { return chalk.yellow('✎ MODIFY');
cleaned = turndownService.turndown(cleaned); case 'reference':
return chalk.blue('👁 REFER ');
default:
return chalk.gray(' FILE ');
} }
// Render markdown to terminal
const result = marked(cleaned);
return typeof result === 'string' ? result.trim() : cleaned;
} }
// Configure marked to use terminal renderer with subtle colors /**
marked.use( * Get category display with icon
markedTerminal({ */
// More subtle colors that match the overall design function getCategoryDisplay(category: TaskCategory): string {
code: (code: string) => { const icon = CATEGORY_ICONS[category] || '📋';
// Custom code block handler to preserve formatting return `${icon} ${category}`;
return code }
.split('\n')
.map((line) => ' ' + chalk.cyan(line))
.join('\n');
},
blockquote: chalk.gray.italic,
html: chalk.gray, // Any remaining HTML will be grayed out (should be rare after turndown)
heading: chalk.white.bold, // White bold for headings
hr: chalk.gray,
listitem: chalk.white, // White for list items
paragraph: chalk.white, // White for paragraphs (default text color)
strong: chalk.white.bold, // White bold for strong text
em: chalk.white.italic, // White italic for emphasis
codespan: chalk.cyan, // Cyan for inline code (no background)
del: chalk.dim.strikethrough,
link: chalk.blue,
href: chalk.blue.underline,
// Add more explicit code block handling
showSectionPrefix: false,
unescape: true,
emoji: false,
// Try to preserve whitespace in code blocks
tab: 4,
width: 120
}) as MarkedExtension
);
// Also set marked options to preserve whitespace // ============================================================================
marked.setOptions({ // Display Functions
breaks: true, // ============================================================================
gfm: true
});
/** /**
* Display the task header with tag * Display the task header with tag
@@ -136,6 +116,17 @@ export function displayTaskProperties(
// Render description with markdown/HTML support (handles tiptap HTML from Hamster) // Render description with markdown/HTML support (handles tiptap HTML from Hamster)
const renderedDescription = renderContent(task.description || ''); const renderedDescription = renderContent(task.description || '');
// Format category with icon
const categoryDisplay = task.category
? `${getCategoryDisplay(task.category)}`
: chalk.gray('N/A');
// Format skills as badges
const skillsDisplay =
task.skills && task.skills.length > 0
? task.skills.map((s) => chalk.magenta(`[${s}]`)).join(' ')
: chalk.gray('N/A');
// Build the left column (labels) and right column (values) // Build the left column (labels) and right column (values)
const labels = [ const labels = [
chalk.cyan('ID:'), chalk.cyan('ID:'),
@@ -144,6 +135,8 @@ export function displayTaskProperties(
chalk.cyan('Priority:'), chalk.cyan('Priority:'),
chalk.cyan('Dependencies:'), chalk.cyan('Dependencies:'),
chalk.cyan('Complexity:'), chalk.cyan('Complexity:'),
chalk.cyan('Category:'),
chalk.cyan('Skills:'),
chalk.cyan('Description:') chalk.cyan('Description:')
].join('\n'); ].join('\n');
@@ -156,6 +149,8 @@ export function displayTaskProperties(
typeof task.complexity === 'number' typeof task.complexity === 'number'
? getComplexityWithColor(task.complexity) ? getComplexityWithColor(task.complexity)
: chalk.gray('N/A'), : chalk.gray('N/A'),
categoryDisplay,
skillsDisplay,
renderedDescription renderedDescription
].join('\n'); ].join('\n');
@@ -273,6 +268,260 @@ export function displaySubtasks(
console.log(table.toString()); console.log(table.toString());
} }
// ============================================================================
// AI Implementation Metadata Display Functions
// ============================================================================
/**
* Display relevant files in a structured format
*/
export function displayRelevantFiles(files: RelevantFile[]): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const content = files
.map((file) => {
const actionIcon = getFileActionIcon(file.action);
const path = chalk.white(file.path);
const desc = chalk.gray(file.description);
return `${actionIcon} ${path}\n ${desc}`;
})
.join('\n\n');
console.log(
boxen(chalk.white.bold('📂 Files to Touch:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'yellow',
width: terminalWidth
})
);
}
/**
* Display existing infrastructure to leverage
*/
export function displayExistingInfrastructure(
infrastructure: ExistingInfrastructure[]
): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const content = infrastructure
.map((infra) => {
const name = chalk.cyan.bold(infra.name);
const location = chalk.gray(infra.location);
const usage = chalk.white(infra.usage);
return `${name}${location}\n ↳ ${usage}`;
})
.join('\n\n');
console.log(
boxen(chalk.white.bold('🔗 Leverage Existing Code:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'blue',
width: terminalWidth
})
);
}
/**
* Display scope boundaries (what's in/out of scope)
*/
export function displayScopeBoundaries(boundaries: ScopeBoundaries): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
let content = '';
if (boundaries.included) {
content += chalk.green.bold('✅ In Scope:\n');
content += chalk.white(' ' + boundaries.included);
}
if (boundaries.excluded) {
if (content) content += '\n\n';
content += chalk.red.bold('⛔ Out of Scope:\n');
content += chalk.gray(' ' + boundaries.excluded);
}
console.log(
boxen(chalk.white.bold('🎯 Scope Boundaries:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'magenta',
width: terminalWidth
})
);
}
/**
* Display acceptance criteria as a checklist
*/
export function displayAcceptanceCriteria(criteria: string[]): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const content = criteria.map((c) => chalk.white(`${c}`)).join('\n');
console.log(
boxen(chalk.white.bold('✓ Acceptance Criteria:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'green',
width: terminalWidth
})
);
}
/**
* Display technical constraints
*/
export function displayTechnicalConstraints(constraints: string[]): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const content = constraints.map((c) => chalk.yellow(`${c}`)).join('\n');
console.log(
boxen(chalk.white.bold('🔒 Technical Constraints:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'red',
width: terminalWidth
})
);
}
/**
* Display implementation approach (step-by-step guide)
*/
export function displayImplementationApproach(approach: string): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const formattedApproach = renderContent(approach);
console.log(
boxen(
chalk.white.bold('📋 Implementation Approach:') +
'\n\n' +
formattedApproach,
{
padding: 1,
borderStyle: 'round',
borderColor: 'cyan',
width: terminalWidth
}
)
);
}
/**
* Display codebase patterns to follow
*/
export function displayCodebasePatterns(patterns: string[]): void {
const terminalWidth = process.stdout.columns * 0.95 || 100;
const content = patterns.map((p) => chalk.white(`${p}`)).join('\n');
console.log(
boxen(chalk.white.bold('📐 Codebase Patterns:') + '\n\n' + content, {
padding: 1,
borderStyle: 'round',
borderColor: 'gray',
width: terminalWidth
})
);
}
/**
* Display skills and category as inline badges
*/
export function displaySkillsAndCategory(
category?: TaskCategory,
skills?: string[]
): void {
let output = '';
if (category) {
output +=
chalk.gray('Category: ') +
chalk.cyan(`[${getCategoryDisplay(category)}]`);
}
if (skills && skills.length > 0) {
if (output) output += ' ';
output +=
chalk.gray('Skills: ') +
skills.map((s) => chalk.magenta(`[${s}]`)).join(' ');
}
if (output) {
console.log('\n' + output);
}
}
/**
* Display all implementation metadata for a task
* Shows all AI-generated guidance when available
* Note: Category and skills are displayed in the main properties table
*/
export function displayImplementationMetadata(task: Task | Subtask): void {
const hasMetadata =
task.relevantFiles ||
task.existingInfrastructure ||
task.scopeBoundaries ||
task.acceptanceCriteria ||
task.technicalConstraints ||
task.implementationApproach ||
task.codebasePatterns;
if (!hasMetadata) {
return;
}
// Display implementation approach
if (task.implementationApproach) {
console.log();
displayImplementationApproach(task.implementationApproach);
}
// Display relevant files
if (task.relevantFiles && task.relevantFiles.length > 0) {
console.log();
displayRelevantFiles(task.relevantFiles);
}
// Display existing infrastructure
if (task.existingInfrastructure && task.existingInfrastructure.length > 0) {
console.log();
displayExistingInfrastructure(task.existingInfrastructure);
}
// Display codebase patterns
if (task.codebasePatterns && task.codebasePatterns.length > 0) {
console.log();
displayCodebasePatterns(task.codebasePatterns);
}
// Display scope boundaries
if (task.scopeBoundaries) {
console.log();
displayScopeBoundaries(task.scopeBoundaries);
}
// Display technical constraints
if (task.technicalConstraints && task.technicalConstraints.length > 0) {
console.log();
displayTechnicalConstraints(task.technicalConstraints);
}
// Display acceptance criteria
if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) {
console.log();
displayAcceptanceCriteria(task.acceptanceCriteria);
}
}
// ============================================================================
// Suggested Actions
// ============================================================================
/** /**
* Display suggested actions * Display suggested actions
*/ */
@@ -349,6 +598,9 @@ export function displayTaskDetails(
displayTestStrategy(task.testStrategy as string); displayTestStrategy(task.testStrategy as string);
} }
// Display AI implementation metadata (relevantFiles, codebasePatterns, etc.)
displayImplementationMetadata(task);
// Display subtasks if available // Display subtasks if available
if (task.subtasks && task.subtasks.length > 0) { if (task.subtasks && task.subtasks.length > 0) {
// Filter subtasks by status if provided // Filter subtasks by status if provided

View File

@@ -0,0 +1,79 @@
/**
* @fileoverview Content rendering utilities for CLI
* Handles HTML to Markdown conversion and terminal-friendly rendering
*/
import chalk from 'chalk';
import { MarkedExtension, marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import TurndownService from 'turndown';
// Initialize turndown for HTML to Markdown conversion
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-'
});
// Configure marked to use terminal renderer with subtle colors
marked.use(
markedTerminal({
// More subtle colors that match the overall design
code: (code: string) => {
// Custom code block handler to preserve formatting
return code
.split('\n')
.map((line) => ' ' + chalk.cyan(line))
.join('\n');
},
blockquote: chalk.gray.italic,
html: chalk.gray, // Any remaining HTML will be grayed out (should be rare after turndown)
heading: chalk.white.bold, // White bold for headings
hr: chalk.gray,
listitem: chalk.white, // White for list items
paragraph: chalk.white, // White for paragraphs (default text color)
strong: chalk.white.bold, // White bold for strong text
em: chalk.white.italic, // White italic for emphasis
codespan: chalk.cyan, // Cyan for inline code (no background)
del: chalk.dim.strikethrough,
link: chalk.blue,
href: chalk.blue.underline,
// Add more explicit code block handling
showSectionPrefix: false,
unescape: true,
emoji: false,
// Try to preserve whitespace in code blocks
tab: 4,
width: 120
}) as MarkedExtension
);
// Also set marked options to preserve whitespace
marked.setOptions({
breaks: true,
gfm: true
});
/**
* Convert HTML content to Markdown, then render for terminal
* Handles tiptap HTML from Hamster gracefully
*/
export function renderContent(content: string): string {
if (!content) return '';
// Clean up escape characters first - order matters: handle escaped backslashes first
let cleaned = content
.replace(/\\\\/g, '\\')
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"');
// Check if content has HTML tags - if so, convert to markdown first
if (/<[^>]+>/.test(cleaned)) {
cleaned = turndownService.turndown(cleaned);
}
// Render markdown to terminal
const result = marked(cleaned);
return typeof result === 'string' ? result.trim() : cleaned;
}

View File

@@ -46,3 +46,6 @@ export {
// Display helpers (command-specific helpers) // Display helpers (command-specific helpers)
export { displayCommandHeader } from './display-helpers.js'; export { displayCommandHeader } from './display-helpers.js';
// Content rendering (HTML/Markdown to terminal)
export { renderContent } from './content-renderer.js';

View File

@@ -0,0 +1,423 @@
/**
* @fileoverview Unit tests for TaskMapper
*
* Tests the mapping of database task rows to internal Task format,
* with focus on metadata extraction including AI implementation guidance fields.
*/
import { describe, expect, it } from 'vitest';
import { TaskMapper } from './TaskMapper.js';
import { MetadataFixtures } from '../../testing/task-fixtures.js';
import type { Tables } from '../types/database.types.js';
type TaskRow = Tables<'tasks'>;
/**
* Creates a mock database task row for testing
*/
function createMockTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
return {
id: 'uuid-123',
display_id: 'HAM-1',
title: 'Test Task',
description: 'Test description',
status: 'todo',
priority: 'medium',
brief_id: 'brief-123',
parent_task_id: null,
position: 1,
subtask_position: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
metadata: {},
complexity: null,
estimated_hours: null,
actual_hours: 0,
assignee_id: null,
document_id: null,
account_id: 'account-123',
created_by: 'user-123',
updated_by: 'user-123',
completed_subtasks: 0,
total_subtasks: 0,
due_date: null,
...overrides
};
}
describe('TaskMapper', () => {
describe('extractImplementationMetadata', () => {
it('should extract all fields from complete metadata', () => {
const result = TaskMapper.extractImplementationMetadata(
MetadataFixtures.completeMetadata
);
expect(result.relevantFiles).toEqual([
{
path: 'src/service.ts',
description: 'Main service file',
action: 'modify'
}
]);
expect(result.codebasePatterns).toEqual([
'Use dependency injection',
'Follow SOLID principles'
]);
expect(result.existingInfrastructure).toEqual([
{
name: 'Logger',
location: 'src/common/logger.ts',
usage: 'Use for structured logging'
}
]);
expect(result.scopeBoundaries).toEqual({
included: 'Core functionality',
excluded: 'UI changes'
});
expect(result.implementationApproach).toBe(
'Step-by-step implementation guide'
);
expect(result.technicalConstraints).toEqual([
'Must be backwards compatible'
]);
expect(result.acceptanceCriteria).toEqual([
'Feature works as expected',
'Tests pass'
]);
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
expect(result.category).toBe('development');
});
it('should return undefined for missing fields', () => {
const result = TaskMapper.extractImplementationMetadata(
MetadataFixtures.minimalMetadata
);
expect(result.relevantFiles).toBeUndefined();
expect(result.codebasePatterns).toBeUndefined();
expect(result.existingInfrastructure).toBeUndefined();
expect(result.scopeBoundaries).toBeUndefined();
expect(result.implementationApproach).toBeUndefined();
expect(result.technicalConstraints).toBeUndefined();
expect(result.acceptanceCriteria).toBeUndefined();
expect(result.skills).toBeUndefined();
expect(result.category).toBeUndefined();
});
it('should handle null metadata gracefully', () => {
const result = TaskMapper.extractImplementationMetadata(null);
expect(result.relevantFiles).toBeUndefined();
expect(result.codebasePatterns).toBeUndefined();
expect(result.category).toBeUndefined();
});
it('should handle undefined metadata gracefully', () => {
const result = TaskMapper.extractImplementationMetadata(undefined);
expect(result.relevantFiles).toBeUndefined();
expect(result.skills).toBeUndefined();
});
it('should handle empty metadata object', () => {
const result = TaskMapper.extractImplementationMetadata(
MetadataFixtures.emptyMetadata
);
expect(result.relevantFiles).toBeUndefined();
expect(result.codebasePatterns).toBeUndefined();
});
it('should filter invalid items from arrays', () => {
const result = TaskMapper.extractImplementationMetadata(
MetadataFixtures.malformedMetadata
);
// codebasePatterns has [123, null, 'valid'] - should only keep 'valid'
expect(result.codebasePatterns).toEqual(['valid']);
// relevantFiles is 'not-an-array' - should be undefined
expect(result.relevantFiles).toBeUndefined();
// existingInfrastructure has invalid structure - should be undefined
expect(result.existingInfrastructure).toBeUndefined();
// scopeBoundaries is 'not-an-object' - should be undefined
expect(result.scopeBoundaries).toBeUndefined();
// category is 'invalid-category' - should be undefined
expect(result.category).toBeUndefined();
// skills is an object - should be undefined
expect(result.skills).toBeUndefined();
});
it('should validate relevantFiles structure', () => {
const metadata = {
relevantFiles: [
{ path: 'valid.ts', description: 'Valid file', action: 'modify' },
{ path: 'missing-action.ts', description: 'Missing action' }, // Invalid
{
path: 'invalid-action.ts',
description: 'Bad action',
action: 'delete'
}, // Invalid action
{ description: 'No path', action: 'create' }, // Missing path
'not-an-object' // Invalid type
]
};
const result = TaskMapper.extractImplementationMetadata(metadata);
expect(result.relevantFiles).toEqual([
{ path: 'valid.ts', description: 'Valid file', action: 'modify' }
]);
});
it('should validate existingInfrastructure structure', () => {
const metadata = {
existingInfrastructure: [
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' },
{ name: 'Missing location', usage: 'Use it' }, // Invalid
{ location: 'src/no-name.ts', usage: 'Use it' }, // Invalid
{ name: 'No usage', location: 'src/test.ts' }, // Invalid
'not-an-object' // Invalid type
]
};
const result = TaskMapper.extractImplementationMetadata(metadata);
expect(result.existingInfrastructure).toEqual([
{ name: 'Valid', location: 'src/valid.ts', usage: 'Use it' }
]);
});
it('should handle scopeBoundaries with partial data', () => {
const metadataIncludedOnly = {
scopeBoundaries: { included: 'Just included' }
};
const resultIncluded =
TaskMapper.extractImplementationMetadata(metadataIncludedOnly);
expect(resultIncluded.scopeBoundaries).toEqual({
included: 'Just included'
});
const metadataExcludedOnly = {
scopeBoundaries: { excluded: 'Just excluded' }
};
const resultExcluded =
TaskMapper.extractImplementationMetadata(metadataExcludedOnly);
expect(resultExcluded.scopeBoundaries).toEqual({
excluded: 'Just excluded'
});
// Empty scopeBoundaries object should return undefined
const metadataEmpty = {
scopeBoundaries: {}
};
const resultEmpty =
TaskMapper.extractImplementationMetadata(metadataEmpty);
expect(resultEmpty.scopeBoundaries).toBeUndefined();
});
it('should validate category enum values', () => {
const validCategories = [
'research',
'design',
'development',
'testing',
'documentation',
'review'
] as const;
for (const category of validCategories) {
const result = TaskMapper.extractImplementationMetadata({ category });
expect(result.category).toBe(category);
}
// Invalid category
const invalidResult = TaskMapper.extractImplementationMetadata({
category: 'invalid'
});
expect(invalidResult.category).toBeUndefined();
});
});
describe('mapDatabaseTaskToTask', () => {
it('should map basic task fields correctly', () => {
const dbTask = createMockTaskRow();
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
expect(result.id).toBe('HAM-1');
expect(result.databaseId).toBe('uuid-123');
expect(result.title).toBe('Test Task');
expect(result.description).toBe('Test description');
expect(result.status).toBe('pending'); // 'todo' maps to 'pending'
expect(result.priority).toBe('medium');
});
it('should extract implementation metadata from task', () => {
const dbTask = createMockTaskRow({
metadata: MetadataFixtures.completeMetadata
});
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
expect(result.relevantFiles).toBeDefined();
expect(result.relevantFiles?.[0].path).toBe('src/service.ts');
expect(result.codebasePatterns).toEqual([
'Use dependency injection',
'Follow SOLID principles'
]);
expect(result.category).toBe('development');
expect(result.skills).toEqual(['TypeScript', 'Node.js']);
});
it('should not add undefined metadata fields to result', () => {
const dbTask = createMockTaskRow({
metadata: MetadataFixtures.minimalMetadata
});
const result = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
// These should not be present as properties (not just undefined values)
expect('relevantFiles' in result).toBe(false);
expect('codebasePatterns' in result).toBe(false);
expect('category' in result).toBe(false);
});
it('should map subtasks with implementation metadata', () => {
const parentTask = createMockTaskRow({ id: 'parent-uuid' });
const subtask = createMockTaskRow({
id: 'subtask-uuid',
display_id: 'HAM-1.1',
parent_task_id: 'parent-uuid',
metadata: {
details: 'Subtask details',
testStrategy: 'Subtask tests',
category: 'testing',
skills: ['Jest', 'Vitest'],
acceptanceCriteria: ['Tests pass', 'Coverage > 80%']
}
});
const result = TaskMapper.mapDatabaseTaskToTask(
parentTask,
[subtask],
new Map()
);
expect(result.subtasks).toHaveLength(1);
const mappedSubtask = result.subtasks[0];
expect(mappedSubtask.id).toBe('HAM-1.1');
expect(mappedSubtask.category).toBe('testing');
expect(mappedSubtask.skills).toEqual(['Jest', 'Vitest']);
expect(mappedSubtask.acceptanceCriteria).toEqual([
'Tests pass',
'Coverage > 80%'
]);
});
it('should map status correctly', () => {
const todoTask = createMockTaskRow({ status: 'todo' });
expect(
TaskMapper.mapDatabaseTaskToTask(todoTask, [], new Map()).status
).toBe('pending');
const inProgressTask = createMockTaskRow({ status: 'in_progress' });
expect(
TaskMapper.mapDatabaseTaskToTask(inProgressTask, [], new Map()).status
).toBe('in-progress');
const doneTask = createMockTaskRow({ status: 'done' });
expect(
TaskMapper.mapDatabaseTaskToTask(doneTask, [], new Map()).status
).toBe('done');
});
it('should map priority correctly', () => {
const urgentTask = createMockTaskRow({ priority: 'urgent' });
expect(
TaskMapper.mapDatabaseTaskToTask(urgentTask, [], new Map()).priority
).toBe('critical');
const highTask = createMockTaskRow({ priority: 'high' });
expect(
TaskMapper.mapDatabaseTaskToTask(highTask, [], new Map()).priority
).toBe('high');
const mediumTask = createMockTaskRow({ priority: 'medium' });
expect(
TaskMapper.mapDatabaseTaskToTask(mediumTask, [], new Map()).priority
).toBe('medium');
const lowTask = createMockTaskRow({ priority: 'low' });
expect(
TaskMapper.mapDatabaseTaskToTask(lowTask, [], new Map()).priority
).toBe('low');
});
});
describe('mapDatabaseTasksToTasks', () => {
it('should group subtasks under parent tasks', () => {
const parentTask = createMockTaskRow({
id: 'parent-uuid',
display_id: 'HAM-1'
});
const subtask1 = createMockTaskRow({
id: 'subtask-1-uuid',
display_id: 'HAM-1.1',
parent_task_id: 'parent-uuid',
subtask_position: 1
});
const subtask2 = createMockTaskRow({
id: 'subtask-2-uuid',
display_id: 'HAM-1.2',
parent_task_id: 'parent-uuid',
subtask_position: 2
});
const result = TaskMapper.mapDatabaseTasksToTasks(
[parentTask, subtask1, subtask2],
new Map()
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('HAM-1');
expect(result[0].subtasks).toHaveLength(2);
expect(result[0].subtasks[0].id).toBe('HAM-1.1');
expect(result[0].subtasks[1].id).toBe('HAM-1.2');
});
it('should handle empty task list', () => {
const result = TaskMapper.mapDatabaseTasksToTasks([], new Map());
expect(result).toEqual([]);
});
it('should handle null task list', () => {
const result = TaskMapper.mapDatabaseTasksToTasks(
null as unknown as TaskRow[],
new Map()
);
expect(result).toEqual([]);
});
it('should include implementation metadata in mapped tasks', () => {
const task = createMockTaskRow({
metadata: {
details: 'Task details',
implementationApproach: 'Step by step guide',
technicalConstraints: ['Constraint 1', 'Constraint 2']
}
});
const result = TaskMapper.mapDatabaseTasksToTasks([task], new Map());
expect(result[0].implementationApproach).toBe('Step by step guide');
expect(result[0].technicalConstraints).toEqual([
'Constraint 1',
'Constraint 2'
]);
});
});
});

View File

@@ -1,5 +1,13 @@
import { Database, Tables } from '../types/database.types.js'; import { Database, Tables } from '../types/database.types.js';
import { Subtask, Task } from '../types/index.js'; import type {
ExistingInfrastructure,
RelevantFile,
ScopeBoundaries,
Subtask,
Task,
TaskCategory,
TaskImplementationMetadata
} from '../types/index.js';
type TaskRow = Tables<'tasks'>; type TaskRow = Tables<'tasks'>;
@@ -51,27 +59,35 @@ export class TaskMapper {
dbSubtasks: TaskRow[], dbSubtasks: TaskRow[],
dependenciesByTaskId: Map<string, string[]> dependenciesByTaskId: Map<string, string[]>
): Task { ): Task {
// Map subtasks // Map subtasks with implementation metadata
const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => ({ const subtasks: Subtask[] = dbSubtasks.map((subtask, index) => {
id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage) const implMeta = this.extractImplementationMetadata(subtask.metadata);
parentId: dbTask.id, return {
title: subtask.title, id: subtask.display_id || String(index + 1), // Use display_id if available (API storage), fallback to numeric (file storage)
description: subtask.description || '', parentId: dbTask.id,
status: this.mapStatus(subtask.status), title: subtask.title,
priority: this.mapPriority(subtask.priority), description: subtask.description || '',
dependencies: dependenciesByTaskId.get(subtask.id) || [], status: this.mapStatus(subtask.status),
details: this.extractMetadataField(subtask.metadata, 'details', ''), priority: this.mapPriority(subtask.priority),
testStrategy: this.extractMetadataField( dependencies: dependenciesByTaskId.get(subtask.id) || [],
subtask.metadata, details: this.extractMetadataField(subtask.metadata, 'details', ''),
'testStrategy', testStrategy: this.extractMetadataField(
'' subtask.metadata,
), 'testStrategy',
createdAt: subtask.created_at, ''
updatedAt: subtask.updated_at, ),
assignee: subtask.assignee_id || undefined, createdAt: subtask.created_at,
complexity: subtask.complexity ?? undefined, updatedAt: subtask.updated_at,
databaseId: subtask.id // Include the actual database UUID assignee: subtask.assignee_id || undefined,
})); complexity: subtask.complexity ?? undefined,
databaseId: subtask.id, // Include the actual database UUID
// Spread implementation metadata (only defined values)
...this.filterUndefined(implMeta)
};
});
// Extract implementation metadata for the task
const implMeta = this.extractImplementationMetadata(dbTask.metadata);
return { return {
id: dbTask.display_id || dbTask.id, // Use display_id if available id: dbTask.display_id || dbTask.id, // Use display_id if available
@@ -93,7 +109,9 @@ export class TaskMapper {
assignee: dbTask.assignee_id || undefined, assignee: dbTask.assignee_id || undefined,
complexity: dbTask.complexity ?? undefined, complexity: dbTask.complexity ?? undefined,
effort: dbTask.estimated_hours || undefined, effort: dbTask.estimated_hours || undefined,
actualEffort: dbTask.actual_hours || undefined actualEffort: dbTask.actual_hours || undefined,
// Spread implementation metadata (only defined values)
...this.filterUndefined(implMeta)
}; };
} }
@@ -215,4 +233,182 @@ export class TaskMapper {
return value as T; return value as T;
} }
/**
* Safely extracts an optional string field from metadata
*/
private static extractOptionalString(
metadata: unknown,
field: string
): string | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>)[field];
return typeof value === 'string' ? value : undefined;
}
/**
* Safely extracts an optional string array from metadata
*/
private static extractStringArray(
metadata: unknown,
field: string
): string[] | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>)[field];
if (!Array.isArray(value)) {
return undefined;
}
// Filter to only valid strings
const strings = value.filter(
(item): item is string => typeof item === 'string'
);
return strings.length > 0 ? strings : undefined;
}
/**
* Safely extracts RelevantFile[] from metadata
*/
private static extractRelevantFiles(
metadata: unknown
): RelevantFile[] | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>).relevantFiles;
if (!Array.isArray(value)) {
return undefined;
}
const validFiles = value.filter((item): item is RelevantFile => {
if (!item || typeof item !== 'object') return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.path === 'string' &&
typeof obj.description === 'string' &&
(obj.action === 'create' ||
obj.action === 'modify' ||
obj.action === 'reference')
);
});
return validFiles.length > 0 ? validFiles : undefined;
}
/**
* Safely extracts ExistingInfrastructure[] from metadata
*/
private static extractExistingInfrastructure(
metadata: unknown
): ExistingInfrastructure[] | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>).existingInfrastructure;
if (!Array.isArray(value)) {
return undefined;
}
const validInfra = value.filter((item): item is ExistingInfrastructure => {
if (!item || typeof item !== 'object') return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.name === 'string' &&
typeof obj.location === 'string' &&
typeof obj.usage === 'string'
);
});
return validInfra.length > 0 ? validInfra : undefined;
}
/**
* Safely extracts ScopeBoundaries from metadata
*/
private static extractScopeBoundaries(
metadata: unknown
): ScopeBoundaries | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>).scopeBoundaries;
if (!value || typeof value !== 'object') {
return undefined;
}
const obj = value as Record<string, unknown>;
const result: ScopeBoundaries = {};
if (typeof obj.included === 'string') {
result.included = obj.included;
}
if (typeof obj.excluded === 'string') {
result.excluded = obj.excluded;
}
// Return undefined if no valid fields
return result.included || result.excluded ? result : undefined;
}
/**
* Safely extracts TaskCategory from metadata
*/
private static extractCategory(metadata: unknown): TaskCategory | undefined {
if (!metadata || typeof metadata !== 'object') {
return undefined;
}
const value = (metadata as Record<string, unknown>).category;
const validCategories: TaskCategory[] = [
'research',
'design',
'development',
'testing',
'documentation',
'review'
];
return validCategories.includes(value as TaskCategory)
? (value as TaskCategory)
: undefined;
}
/**
* Extracts all AI implementation metadata fields from database metadata
*/
static extractImplementationMetadata(
metadata: unknown
): TaskImplementationMetadata {
return {
relevantFiles: this.extractRelevantFiles(metadata),
codebasePatterns: this.extractStringArray(metadata, 'codebasePatterns'),
existingInfrastructure: this.extractExistingInfrastructure(metadata),
scopeBoundaries: this.extractScopeBoundaries(metadata),
implementationApproach: this.extractOptionalString(
metadata,
'implementationApproach'
),
technicalConstraints: this.extractStringArray(
metadata,
'technicalConstraints'
),
acceptanceCriteria: this.extractStringArray(
metadata,
'acceptanceCriteria'
),
skills: this.extractStringArray(metadata, 'skills'),
category: this.extractCategory(metadata)
};
}
/**
* Filters out undefined values from an object
* Used to avoid adding undefined properties to task objects
*/
private static filterUndefined<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined)
) as Partial<T>;
}
} }

View File

@@ -37,6 +37,80 @@ export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
*/ */
export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex'; export type TaskComplexity = 'simple' | 'moderate' | 'complex' | 'very-complex';
// ============================================================================
// AI Metadata Types (from Supabase task metadata)
// ============================================================================
/**
* File relevant to implementing a task/subtask
*/
export interface RelevantFile {
/** File path relative to project root */
path: string;
/** What this file contains and how it relates to the task */
description: string;
/** Whether to create, modify, or just reference this file */
action: 'create' | 'modify' | 'reference';
}
/**
* Existing infrastructure to leverage
*/
export interface ExistingInfrastructure {
/** Name of the existing service/module/infrastructure */
name: string;
/** Where it exists in the codebase */
location: string;
/** How to use or integrate with it */
usage: string;
}
/**
* Scope boundaries for a task
*/
export interface ScopeBoundaries {
/** What is explicitly in scope for this task */
included?: string;
/** What is explicitly out of scope (belongs to other tasks/already exists) */
excluded?: string;
}
/**
* Task work category
*/
export type TaskCategory =
| 'research'
| 'design'
| 'development'
| 'testing'
| 'documentation'
| 'review';
/**
* AI-generated implementation guidance metadata
* These fields provide rich context for AI agents and developers
*/
export interface TaskImplementationMetadata {
/** Files relevant to implementing this task */
relevantFiles?: RelevantFile[];
/** Existing code patterns, conventions, or architectural principles to follow */
codebasePatterns?: string[];
/** Existing services, modules, or infrastructure to leverage */
existingInfrastructure?: ExistingInfrastructure[];
/** Clear boundaries of what this task should and should not do */
scopeBoundaries?: ScopeBoundaries;
/** Step-by-step implementation guidance or pseudo-code */
implementationApproach?: string;
/** Framework requirements, architecture decisions, or technical limitations */
technicalConstraints?: string[];
/** Acceptance criteria defining when this task is complete */
acceptanceCriteria?: string[];
/** Required technical skills to complete this task */
skills?: string[];
/** Category of work this task represents */
category?: TaskCategory;
}
// ============================================================================ // ============================================================================
// Core Interfaces // Core Interfaces
// ============================================================================ // ============================================================================
@@ -54,7 +128,7 @@ export interface PlaceholderTask {
/** /**
* Base task interface * Base task interface
*/ */
export interface Task { export interface Task extends TaskImplementationMetadata {
id: string; id: string;
title: string; title: string;
description: string; description: string;

View File

@@ -20,6 +20,7 @@ export {
createSubtask, createSubtask,
createTasksFile, createTasksFile,
TaskScenarios, TaskScenarios,
MetadataFixtures,
type TasksFile type TasksFile
} from './task-fixtures.js'; } from './task-fixtures.js';

View File

@@ -80,7 +80,29 @@ export function createTask(
}), }),
...(overrides.complexityReasoning && { ...(overrides.complexityReasoning && {
complexityReasoning: overrides.complexityReasoning complexityReasoning: overrides.complexityReasoning
}) }),
// AI implementation metadata fields
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
...(overrides.codebasePatterns && {
codebasePatterns: overrides.codebasePatterns
}),
...(overrides.existingInfrastructure && {
existingInfrastructure: overrides.existingInfrastructure
}),
...(overrides.scopeBoundaries && {
scopeBoundaries: overrides.scopeBoundaries
}),
...(overrides.implementationApproach && {
implementationApproach: overrides.implementationApproach
}),
...(overrides.technicalConstraints && {
technicalConstraints: overrides.technicalConstraints
}),
...(overrides.acceptanceCriteria && {
acceptanceCriteria: overrides.acceptanceCriteria
}),
...(overrides.skills && { skills: overrides.skills }),
...(overrides.category && { category: overrides.category })
}; };
} }
@@ -134,7 +156,29 @@ export function createSubtask(
}), }),
...(overrides.complexityReasoning && { ...(overrides.complexityReasoning && {
complexityReasoning: overrides.complexityReasoning complexityReasoning: overrides.complexityReasoning
}) }),
// AI implementation metadata fields
...(overrides.relevantFiles && { relevantFiles: overrides.relevantFiles }),
...(overrides.codebasePatterns && {
codebasePatterns: overrides.codebasePatterns
}),
...(overrides.existingInfrastructure && {
existingInfrastructure: overrides.existingInfrastructure
}),
...(overrides.scopeBoundaries && {
scopeBoundaries: overrides.scopeBoundaries
}),
...(overrides.implementationApproach && {
implementationApproach: overrides.implementationApproach
}),
...(overrides.technicalConstraints && {
technicalConstraints: overrides.technicalConstraints
}),
...(overrides.acceptanceCriteria && {
acceptanceCriteria: overrides.acceptanceCriteria
}),
...(overrides.skills && { skills: overrides.skills }),
...(overrides.category && { category: overrides.category })
}; };
} }
@@ -303,5 +347,149 @@ export const TaskScenarios = {
/** /**
* Empty task list * Empty task list
*/ */
empty: () => createTasksFile({ tasks: [] }) empty: () => createTasksFile({ tasks: [] }),
/**
* Task with rich AI-generated implementation metadata
*/
taskWithImplementationMetadata: () =>
createTasksFile({
tasks: [
createTask({
id: 1,
title: 'Implement User Authentication',
description: 'Add JWT-based authentication to the API',
details: 'Implement secure JWT authentication with refresh tokens',
testStrategy:
'Unit tests for auth functions, integration tests for flow',
category: 'development',
skills: ['TypeScript', 'JWT', 'Security'],
relevantFiles: [
{
path: 'src/auth/auth.service.ts',
description: 'Main authentication service',
action: 'modify'
},
{
path: 'src/auth/jwt.strategy.ts',
description: 'JWT passport strategy',
action: 'create'
}
],
codebasePatterns: [
'Use dependency injection for services',
'Follow repository pattern for data access'
],
existingInfrastructure: [
{
name: 'UserRepository',
location: 'src/users/user.repository.ts',
usage: 'Use for user lookups during authentication'
}
],
scopeBoundaries: {
included: 'JWT token generation, validation, and refresh',
excluded: 'OAuth integration (handled in task 2)'
},
implementationApproach:
'1. Create JWT strategy\n2. Add auth guards\n3. Implement refresh token flow',
technicalConstraints: [
'Must use RS256 algorithm',
'Tokens must expire in 15 minutes'
],
acceptanceCriteria: [
'Users can login with email/password',
'JWT tokens are issued on successful login',
'Refresh tokens work correctly'
],
subtasks: [
createSubtask({
id: '1.1',
title: 'Create JWT Strategy',
category: 'development',
skills: ['TypeScript', 'Passport.js'],
relevantFiles: [
{
path: 'src/auth/jwt.strategy.ts',
description: 'JWT passport strategy implementation',
action: 'create'
}
],
acceptanceCriteria: ['Strategy validates JWT tokens correctly']
}),
createSubtask({
id: '1.2',
title: 'Implement Auth Guards',
category: 'development',
implementationApproach: 'Create NestJS guards using JWT strategy',
technicalConstraints: ['Must work with role-based access control']
})
]
})
]
})
};
/**
* Sample metadata fixtures for testing metadata extraction
*/
export const MetadataFixtures = {
/**
* Complete metadata object with all fields populated
*/
completeMetadata: {
details: 'Detailed task requirements and scope',
testStrategy: 'Unit and integration tests',
relevantFiles: [
{
path: 'src/service.ts',
description: 'Main service file',
action: 'modify' as const
}
],
codebasePatterns: ['Use dependency injection', 'Follow SOLID principles'],
existingInfrastructure: [
{
name: 'Logger',
location: 'src/common/logger.ts',
usage: 'Use for structured logging'
}
],
scopeBoundaries: {
included: 'Core functionality',
excluded: 'UI changes'
},
implementationApproach: 'Step-by-step implementation guide',
technicalConstraints: ['Must be backwards compatible'],
acceptanceCriteria: ['Feature works as expected', 'Tests pass'],
skills: ['TypeScript', 'Node.js'],
category: 'development' as const
},
/**
* Minimal metadata with only required fields
*/
minimalMetadata: {
details: 'Basic details',
testStrategy: 'Basic tests'
},
/**
* Metadata with invalid/malformed data (for testing robustness)
*/
malformedMetadata: {
details: 123, // Should be string
testStrategy: null, // Should be string
relevantFiles: 'not-an-array', // Should be array
codebasePatterns: [123, null, 'valid'], // Mixed invalid types
existingInfrastructure: [{ invalid: 'structure' }], // Missing required fields
scopeBoundaries: 'not-an-object', // Should be object
category: 'invalid-category', // Invalid enum value
skills: { not: 'an-array' } // Should be array
},
/**
* Empty metadata object
*/
emptyMetadata: {}
}; };

View File

@@ -0,0 +1,495 @@
/**
* @fileoverview Integration tests for task metadata extraction across storage modes
*
* These tests verify that rich AI-generated implementation metadata is correctly
* extracted and passed through the storage layer for both file and API storage modes.
*
* For API storage: Tests the flow from database rows -> TaskMapper -> Task type
* For file storage: Tests that metadata is preserved in JSON serialization/deserialization
*/
import { describe, expect, it } from 'vitest';
import { TaskMapper } from '../../../src/common/mappers/TaskMapper.js';
import type { Json, Tables } from '../../../src/common/types/database.types.js';
import type {
ExistingInfrastructure,
RelevantFile,
Task
} from '../../../src/common/types/index.js';
type TaskRow = Tables<'tasks'>;
/**
* Creates a realistic database task row with AI-generated metadata
*/
function createDatabaseTaskRow(overrides: Partial<TaskRow> = {}): TaskRow {
return {
id: '550e8400-e29b-41d4-a716-446655440000',
display_id: 'HAM-1',
title: 'Implement Authentication System',
description: 'Add JWT-based authentication to the API',
status: 'in_progress',
priority: 'high',
brief_id: 'brief-550e8400',
parent_task_id: null,
position: 1,
subtask_position: 0,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T14:30:00Z',
metadata: {
details:
'Implement secure JWT authentication with refresh tokens and role-based access control',
testStrategy:
'Unit tests for auth functions, integration tests for login flow, E2E tests for protected routes',
relevantFiles: [
{
path: 'src/auth/auth.service.ts',
description: 'Main authentication service handling login/logout',
action: 'modify'
},
{
path: 'src/auth/jwt.strategy.ts',
description: 'Passport JWT strategy for token validation',
action: 'create'
},
{
path: 'src/auth/guards/auth.guard.ts',
description: 'NestJS guard for protected routes',
action: 'create'
}
],
codebasePatterns: [
'Use dependency injection for all services',
'Follow repository pattern for data access',
'Use DTOs for request/response validation'
],
existingInfrastructure: [
{
name: 'UserRepository',
location: 'src/users/user.repository.ts',
usage: 'Use for user lookups during authentication'
},
{
name: 'ConfigService',
location: 'src/config/config.service.ts',
usage: 'Access JWT secret and token expiry settings'
}
],
scopeBoundaries: {
included:
'JWT token generation, validation, refresh token flow, auth guards',
excluded:
'OAuth/social login (separate task), password reset (separate task)'
},
implementationApproach: `1. Create JWT strategy extending PassportStrategy
2. Implement AuthService with login/validateUser methods
3. Create AuthGuard for route protection
4. Add refresh token endpoint
5. Update user entity with hashed password storage`,
technicalConstraints: [
'Must use RS256 algorithm for JWT signing',
'Access tokens must expire in 15 minutes',
'Refresh tokens must expire in 7 days',
'Passwords must be hashed with bcrypt (cost factor 12)'
],
acceptanceCriteria: [
'Users can register with email and password',
'Users can login and receive JWT tokens',
'Protected routes reject requests without valid tokens',
'Refresh tokens can be used to get new access tokens',
'All auth-related errors return appropriate HTTP status codes'
],
skills: ['TypeScript', 'NestJS', 'JWT', 'Passport.js', 'bcrypt'],
category: 'development'
} as Json,
complexity: 7,
estimated_hours: 16,
actual_hours: 0,
assignee_id: null,
document_id: null,
account_id: 'account-123',
created_by: 'user-123',
updated_by: 'user-123',
completed_subtasks: 0,
total_subtasks: 3,
due_date: '2024-01-20T17:00:00Z',
...overrides
};
}
/**
* Creates a database subtask row with implementation metadata
*/
function createDatabaseSubtaskRow(
parentId: string,
subtaskNum: number
): TaskRow {
const subtaskMetadata: Record<number, object> = {
1: {
details: 'Create the JWT strategy class that validates tokens',
testStrategy: 'Unit tests for token validation logic',
relevantFiles: [
{
path: 'src/auth/jwt.strategy.ts',
description: 'JWT strategy implementation',
action: 'create'
}
],
implementationApproach:
'Extend PassportStrategy with jwt-passport, implement validate method',
acceptanceCriteria: [
'Strategy validates JWT tokens',
'Invalid tokens are rejected'
],
skills: ['TypeScript', 'Passport.js'],
category: 'development'
},
2: {
details: 'Create guards for protecting routes',
testStrategy: 'Integration tests with mock requests',
relevantFiles: [
{
path: 'src/auth/guards/auth.guard.ts',
description: 'Main auth guard',
action: 'create'
}
],
technicalConstraints: ['Must work with NestJS execution context'],
acceptanceCriteria: ['Protected routes require valid JWT'],
category: 'development'
},
3: {
details: 'Implement refresh token flow',
testStrategy: 'E2E tests for token refresh',
scopeBoundaries: {
included: 'Refresh token generation and validation',
excluded: 'Token revocation (future enhancement)'
},
acceptanceCriteria: [
'Refresh tokens can get new access tokens',
'Expired refresh tokens are rejected'
],
category: 'development'
}
};
return {
id: `subtask-${subtaskNum}-uuid`,
display_id: `HAM-1.${subtaskNum}`,
title: `Subtask ${subtaskNum}`,
description: `Description for subtask ${subtaskNum}`,
status: 'todo',
priority: 'medium',
brief_id: 'brief-550e8400',
parent_task_id: parentId,
position: 1,
subtask_position: subtaskNum,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
metadata: subtaskMetadata[subtaskNum] as Json,
complexity: null,
estimated_hours: 4,
actual_hours: 0,
assignee_id: null,
document_id: null,
account_id: 'account-123',
created_by: 'user-123',
updated_by: 'user-123',
completed_subtasks: 0,
total_subtasks: 0,
due_date: null
};
}
describe('Task Metadata Extraction - Integration Tests', () => {
describe('API Storage Mode - TaskMapper Integration', () => {
it('should extract complete implementation metadata from database task', () => {
const dbTask = createDatabaseTaskRow();
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
// Verify core fields
expect(task.id).toBe('HAM-1');
expect(task.title).toBe('Implement Authentication System');
expect(task.status).toBe('in-progress');
expect(task.priority).toBe('high');
// Verify details and testStrategy
expect(task.details).toContain('JWT authentication');
expect(task.testStrategy).toContain('Unit tests');
// Verify implementation metadata
expect(task.relevantFiles).toBeDefined();
expect(task.relevantFiles).toHaveLength(3);
expect(task.relevantFiles![0]).toEqual({
path: 'src/auth/auth.service.ts',
description: 'Main authentication service handling login/logout',
action: 'modify'
});
expect(task.codebasePatterns).toEqual([
'Use dependency injection for all services',
'Follow repository pattern for data access',
'Use DTOs for request/response validation'
]);
expect(task.existingInfrastructure).toHaveLength(2);
expect(task.existingInfrastructure![0].name).toBe('UserRepository');
expect(task.scopeBoundaries).toEqual({
included:
'JWT token generation, validation, refresh token flow, auth guards',
excluded:
'OAuth/social login (separate task), password reset (separate task)'
});
expect(task.implementationApproach).toContain('Create JWT strategy');
expect(task.technicalConstraints).toContain(
'Must use RS256 algorithm for JWT signing'
);
expect(task.acceptanceCriteria).toContain(
'Users can register with email and password'
);
expect(task.skills).toEqual([
'TypeScript',
'NestJS',
'JWT',
'Passport.js',
'bcrypt'
]);
expect(task.category).toBe('development');
});
it('should extract metadata from subtasks', () => {
const parentTask = createDatabaseTaskRow();
const subtasks = [
createDatabaseSubtaskRow(parentTask.id, 1),
createDatabaseSubtaskRow(parentTask.id, 2),
createDatabaseSubtaskRow(parentTask.id, 3)
];
const task = TaskMapper.mapDatabaseTaskToTask(
parentTask,
subtasks,
new Map()
);
expect(task.subtasks).toHaveLength(3);
// Verify first subtask has metadata
const subtask1 = task.subtasks[0];
expect(subtask1.id).toBe('HAM-1.1');
expect(subtask1.relevantFiles).toHaveLength(1);
expect(subtask1.skills).toEqual(['TypeScript', 'Passport.js']);
expect(subtask1.category).toBe('development');
expect(subtask1.acceptanceCriteria).toContain(
'Strategy validates JWT tokens'
);
// Verify second subtask has different metadata
const subtask2 = task.subtasks[1];
expect(subtask2.technicalConstraints).toContain(
'Must work with NestJS execution context'
);
// Verify third subtask has scope boundaries
const subtask3 = task.subtasks[2];
expect(subtask3.scopeBoundaries).toBeDefined();
expect(subtask3.scopeBoundaries!.included).toContain('Refresh token');
});
it('should handle tasks without metadata gracefully', () => {
const dbTask = createDatabaseTaskRow({
metadata: {} as Json
});
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
expect(task.id).toBe('HAM-1');
expect(task.details).toBe('');
expect(task.testStrategy).toBe('');
expect(task.relevantFiles).toBeUndefined();
expect(task.codebasePatterns).toBeUndefined();
expect(task.category).toBeUndefined();
});
it('should handle malformed metadata without crashing', () => {
const dbTask = createDatabaseTaskRow({
metadata: {
details: 123, // Invalid: should be string
relevantFiles: 'not-an-array', // Invalid: should be array
category: 'invalid-category', // Invalid: not a valid enum value
skills: { wrong: 'type' } // Invalid: should be array
} as unknown as Json
});
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
// Should not crash and should use defaults
expect(task.id).toBe('HAM-1');
expect(task.details).toBe(''); // Falls back to empty string
expect(task.relevantFiles).toBeUndefined();
expect(task.category).toBeUndefined();
expect(task.skills).toBeUndefined();
});
it('should map multiple tasks with their subtasks correctly', () => {
const parentTask1 = createDatabaseTaskRow({
id: 'parent-1-uuid',
display_id: 'HAM-1'
});
const parentTask2 = createDatabaseTaskRow({
id: 'parent-2-uuid',
display_id: 'HAM-2',
title: 'Second Task'
});
const subtask1_1 = createDatabaseSubtaskRow('parent-1-uuid', 1);
const subtask2_1: TaskRow = {
...createDatabaseSubtaskRow('parent-2-uuid', 1),
display_id: 'HAM-2.1'
};
const allTasks = [parentTask1, parentTask2, subtask1_1, subtask2_1];
const tasks = TaskMapper.mapDatabaseTasksToTasks(allTasks, new Map());
expect(tasks).toHaveLength(2);
expect(tasks[0].id).toBe('HAM-1');
expect(tasks[0].subtasks).toHaveLength(1);
expect(tasks[0].subtasks[0].id).toBe('HAM-1.1');
expect(tasks[1].id).toBe('HAM-2');
expect(tasks[1].subtasks).toHaveLength(1);
expect(tasks[1].subtasks[0].id).toBe('HAM-2.1');
});
});
describe('File Storage Mode - JSON Serialization', () => {
it('should preserve all metadata fields through JSON serialization', () => {
// Create a task with full metadata (simulating what would be stored in tasks.json)
const taskWithMetadata: Task = {
id: '1',
title: 'Test Task',
description: 'Test description',
status: 'pending',
priority: 'high',
dependencies: [],
details: 'Detailed requirements',
testStrategy: 'Unit and integration tests',
subtasks: [],
relevantFiles: [
{ path: 'src/test.ts', description: 'Test file', action: 'modify' }
],
codebasePatterns: ['Pattern 1', 'Pattern 2'],
existingInfrastructure: [
{ name: 'Service', location: 'src/service.ts', usage: 'Use for X' }
],
scopeBoundaries: { included: 'A', excluded: 'B' },
implementationApproach: 'Step by step',
technicalConstraints: ['Constraint 1'],
acceptanceCriteria: ['Criteria 1', 'Criteria 2'],
skills: ['TypeScript'],
category: 'development'
};
// Serialize and deserialize (simulating file storage)
const serialized = JSON.stringify(taskWithMetadata);
const deserialized: Task = JSON.parse(serialized);
// All fields should be preserved
expect(deserialized.relevantFiles).toEqual(
taskWithMetadata.relevantFiles
);
expect(deserialized.codebasePatterns).toEqual(
taskWithMetadata.codebasePatterns
);
expect(deserialized.existingInfrastructure).toEqual(
taskWithMetadata.existingInfrastructure
);
expect(deserialized.scopeBoundaries).toEqual(
taskWithMetadata.scopeBoundaries
);
expect(deserialized.implementationApproach).toBe(
taskWithMetadata.implementationApproach
);
expect(deserialized.technicalConstraints).toEqual(
taskWithMetadata.technicalConstraints
);
expect(deserialized.acceptanceCriteria).toEqual(
taskWithMetadata.acceptanceCriteria
);
expect(deserialized.skills).toEqual(taskWithMetadata.skills);
expect(deserialized.category).toBe(taskWithMetadata.category);
});
it('should handle tasks without optional metadata in JSON', () => {
const minimalTask: Task = {
id: '1',
title: 'Minimal Task',
description: 'Description',
status: 'pending',
priority: 'medium',
dependencies: [],
details: '',
testStrategy: '',
subtasks: []
};
const serialized = JSON.stringify(minimalTask);
const deserialized: Task = JSON.parse(serialized);
expect(deserialized.id).toBe('1');
expect(deserialized.relevantFiles).toBeUndefined();
expect(deserialized.category).toBeUndefined();
});
});
describe('Metadata Type Validation', () => {
it('should correctly type relevantFiles entries', () => {
const dbTask = createDatabaseTaskRow();
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
// TypeScript type checking - if these compile, types are correct
const files: RelevantFile[] | undefined = task.relevantFiles;
expect(files).toBeDefined();
if (files) {
const firstFile: RelevantFile = files[0];
expect(firstFile.path).toBe('src/auth/auth.service.ts');
expect(firstFile.action).toBe('modify');
expect(['create', 'modify', 'reference']).toContain(firstFile.action);
}
});
it('should correctly type existingInfrastructure entries', () => {
const dbTask = createDatabaseTaskRow();
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
const infra: ExistingInfrastructure[] | undefined =
task.existingInfrastructure;
expect(infra).toBeDefined();
if (infra) {
const firstInfra: ExistingInfrastructure = infra[0];
expect(firstInfra.name).toBe('UserRepository');
expect(firstInfra.location).toBe('src/users/user.repository.ts');
expect(firstInfra.usage).toContain('user lookups');
}
});
it('should correctly type category field', () => {
const dbTask = createDatabaseTaskRow();
const task = TaskMapper.mapDatabaseTaskToTask(dbTask, [], new Map());
// Verify category is a valid enum value
const validCategories = [
'research',
'design',
'development',
'testing',
'documentation',
'review'
];
expect(task.category).toBeDefined();
expect(validCategories).toContain(task.category);
});
});
});