';
+ } else if (content.includes('🔍 **Search Results**')) {
+ if (content.includes('No tasks found')) {
+ return '
🔍 Search Results
\n
No tasks found matching query
';
+ } else {
+ return '
🔍 Search Results
\n
1 found
';
+ }
} else if (content.includes('🏆 **Gauntlet - AI Model Evaluation**')) {
return '
🏆 Gauntlet - AI Model Evaluation
\n
Usage:
\n
\n
!gauntlet run --model <model> - Run gauntlet evaluation
\n
\n
Options:
\n
--model <model> - Required. The model name to evaluate
\n
⚠️ Note: Gauntlet only works with OpenAI and Ollama providers, not Copilot.
';
} else if (content.includes('📋 **Available Gauntlet Tasks:**')) {
@@ -362,6 +370,50 @@ Job's done! The program has been created successfully.
expect.stringContaining('
DEVLOG
')
);
});
+
+ it('should show task summary with statistics', async () => {
+ await bot.processMessage('!tasks summary', 'user', mockSendMessage);
+
+ // Verify the summary content
+ const [markdown, html] = mockSendMessage.mock.calls[0];
+ expect(markdown).toContain('📊 **Project Summary**');
+ expect(markdown).toContain('• **Open Tasks:** 1');
+ expect(markdown).toContain('• **Completed Tasks:** 0');
+ expect(markdown).toContain('**By Phase:**');
+ expect(markdown).toContain('View Full Dashboard');
+
+ // Verify HTML formatting
+ expect(html).toContain('Project Summary');
+ });
+
+ it('should search tasks by query', async () => {
+ await bot.processMessage('!tasks search Restructure', 'user', mockSendMessage);
+
+ // Verify the search results
+ const [markdown, html] = mockSendMessage.mock.calls[0];
+ expect(markdown).toContain('🔍 **Search Results**');
+ expect(markdown).toContain('1 found');
+ expect(markdown).toContain('Restructure TASKS.md');
+ expect(markdown).toContain('(in-progress)');
+
+ // Verify HTML formatting
+ expect(html).toContain('Search Results');
+ });
+
+ it('should show no results for tasks search with no matches', async () => {
+ await bot.processMessage('!tasks search nonexistent', 'user', mockSendMessage);
+
+ const [markdown] = mockSendMessage.mock.calls[0];
+ expect(markdown).toContain('🔍 **Search Results**');
+ expect(markdown).toContain('No tasks found matching "nonexistent"');
+ });
+
+ it('should show usage help for tasks search without query', async () => {
+ await bot.processMessage('!tasks search', 'user', mockSendMessage);
+
+ const [message] = mockSendMessage.mock.calls[0];
+ expect(message).toBe('Usage: !tasks search ');
+ });
});
describe('Gauntlet Commands', () => {
diff --git a/src/morpheum-bot/bot.ts b/src/morpheum-bot/bot.ts
index 000c492..b6edcbb 100644
--- a/src/morpheum-bot/bot.ts
+++ b/src/morpheum-bot/bot.ts
@@ -8,7 +8,7 @@ import { execa } from "execa";
import * as fs from "fs";
import { formatMarkdown } from "./format-markdown";
import { CopilotClient } from "./copilotClient";
-import { getTaskFiles, filterUncompletedTasks, assembleTasksMarkdown } from "./task-utils";
+import { getTaskFiles, filterUncompletedTasks, assembleTasksMarkdown, getTaskSummary, searchTasks } from "./task-utils";
import * as net from "net";
import { normalizeArgsArray } from "./dash-normalizer";
import { ProjectRoomManager, ProjectRoomConfig, ProjectRoomCreationOptions } from "./project-room-manager";
@@ -261,6 +261,8 @@ export class MorpheumBot {
Available commands:
- \`!help\` - Show this help message
- \`!tasks\` - Show current tasks
+- \`!tasks summary\` - Show task summary statistics
+- \`!tasks search \` - Search tasks by keyword
- \`!devlog\` - Show development log
- \`!tokens\` - Show Matrix authentication token status
- \`!token refresh\` - Manually refresh Matrix authentication token
@@ -283,12 +285,7 @@ Available commands:
For regular tasks, just type your request without a command prefix.`;
await sendMessage(message);
} else if (body.startsWith("!tasks")) {
- // Read task files from the new directory structure and show only uncompleted tasks
- const allTasks = await getTaskFiles("docs/_tasks");
- const uncompletedTasks = filterUncompletedTasks(allTasks);
- const content = assembleTasksMarkdown(uncompletedTasks);
- const html = formatMarkdown(content);
- await sendMessage(content, html);
+ await this.handleTasksCommand(body, sendMessage);
} else if (body.startsWith("!devlog")) {
const content = await fs.promises.readFile("DEVLOG.md", "utf8");
const html = formatMarkdown(content);
@@ -539,6 +536,84 @@ This room has project-specific settings that override global config for tasks:
}
}
+ private async handleTasksCommand(body: string, sendMessage: MessageSender) {
+ const parts = body.split(' ');
+ const subcommand = parts[1];
+
+ if (!subcommand) {
+ // Default behavior - show open tasks
+ const allTasks = await getTaskFiles("docs/_tasks");
+ const uncompletedTasks = filterUncompletedTasks(allTasks);
+ const content = assembleTasksMarkdown(uncompletedTasks);
+ const html = formatMarkdown(content);
+ await sendMessage(content, html);
+ return;
+ }
+
+ switch (subcommand) {
+ case 'summary':
+ await this.handleTasksSummaryCommand(sendMessage);
+ break;
+ case 'search':
+ await this.handleTasksSearchCommand(parts.slice(2), sendMessage);
+ break;
+ default:
+ await sendPlainTextMessage(
+ 'Unknown tasks subcommand. Available: summary, search',
+ sendMessage
+ );
+ }
+ }
+
+ private async handleTasksSummaryCommand(sendMessage: MessageSender): Promise {
+ try {
+ const summary = await getTaskSummary("docs/_tasks");
+
+ const summaryText = `📊 **Project Summary**\n\n` +
+ `• **Open Tasks:** ${summary.totalOpen}\n` +
+ `• **Completed Tasks:** ${summary.totalCompleted}\n` +
+ `• **Active Phases:** ${Object.keys(summary.byPhase).length}\n\n` +
+ `**By Phase:**\n` +
+ Object.entries(summary.byPhase)
+ .map(([phase, count]) => ` • ${phase}: ${count} tasks`)
+ .join('\n') +
+ `\n\n[View Full Dashboard](https://anicolao.github.io/morpheum/status/tasks/)`;
+
+ await sendMarkdownMessage(summaryText, sendMessage);
+ } catch (error) {
+ await sendPlainTextMessage('Error retrieving task summary.', sendMessage);
+ }
+ }
+
+ private async handleTasksSearchCommand(args: string[], sendMessage: MessageSender): Promise {
+ if (args.length === 0) {
+ await sendPlainTextMessage('Usage: !tasks search ', sendMessage);
+ return;
+ }
+
+ const query = args.join(' ');
+
+ try {
+ const results = await searchTasks(query, { status: ['open', 'in-progress'] });
+
+ if (results.length === 0) {
+ await sendMarkdownMessage(`🔍 **Search Results**\n\nNo tasks found matching "${query}"`, sendMessage);
+ return;
+ }
+
+ const resultText = `🔍 **Search Results** (${results.length} found)\n\n` +
+ results.slice(0, 5).map((task, index) =>
+ `${index + 1}. **${task.title}** (${task.status})\n Phase: ${task.phase || 'None'}`
+ ).join('\n\n') +
+ (results.length > 5 ? `\n\n... and ${results.length - 5} more results` : '') +
+ `\n\n[View All Results](https://anicolao.github.io/morpheum/status/tasks/)`;
+
+ await sendMarkdownMessage(resultText, sendMessage);
+ } catch (error) {
+ await sendPlainTextMessage('Error searching tasks.', sendMessage);
+ }
+ }
+
private async handleGauntletCommand(body: string, sendMessage: MessageSender) {
const parts = body.split(' ');
const subcommand = parts[1];
diff --git a/src/morpheum-bot/task-utils.ts b/src/morpheum-bot/task-utils.ts
index 4d97eda..61da6bf 100644
--- a/src/morpheum-bot/task-utils.ts
+++ b/src/morpheum-bot/task-utils.ts
@@ -11,6 +11,14 @@ export interface TaskMetadata {
filename: string;
}
+export interface TaskSummary {
+ totalOpen: number;
+ totalCompleted: number;
+ byPhase: Record;
+ byCategory: Record;
+ recentlyUpdated: TaskMetadata[];
+}
+
/**
* Simple front matter parser for task files
*/
@@ -152,4 +160,93 @@ export function assembleTasksMarkdown(tasks: TaskMetadata[]): string {
}
return markdown.trim();
+}
+
+/**
+ * Get a summary of task statistics
+ */
+export async function getTaskSummary(tasksDir: string = "docs/_tasks"): Promise {
+ const tasks = await getTaskFiles(tasksDir);
+ const openTasks = filterUncompletedTasks(tasks);
+ const completedTasks = tasks.filter(t => t.status === 'completed');
+
+ return {
+ totalOpen: openTasks.length,
+ totalCompleted: completedTasks.length,
+ byPhase: groupTasksByPhase(openTasks),
+ byCategory: groupTasksByCategory(openTasks),
+ recentlyUpdated: getRecentlyUpdatedTasks(tasks, 7) // Last 7 days
+ };
+}
+
+/**
+ * Group tasks by phase
+ */
+function groupTasksByPhase(tasks: TaskMetadata[]): Record {
+ const grouped: Record = {};
+
+ for (const task of tasks) {
+ const phase = task.phase || 'Other';
+ grouped[phase] = (grouped[phase] || 0) + 1;
+ }
+
+ return grouped;
+}
+
+/**
+ * Group tasks by category
+ */
+function groupTasksByCategory(tasks: TaskMetadata[]): Record {
+ const grouped: Record = {};
+
+ for (const task of tasks) {
+ const category = task.category || 'Other';
+ grouped[category] = (grouped[category] || 0) + 1;
+ }
+
+ return grouped;
+}
+
+/**
+ * Get recently updated tasks (stub implementation)
+ */
+function getRecentlyUpdatedTasks(tasks: TaskMetadata[], days: number): TaskMetadata[] {
+ // For now, just return the most recent 5 tasks by order number
+ // In a real implementation, we'd check file modification times
+ return tasks
+ .filter(t => t.order !== undefined)
+ .sort((a, b) => (b.order || 0) - (a.order || 0))
+ .slice(0, 5);
+}
+
+/**
+ * Search tasks by query and filters
+ */
+export async function searchTasks(query: string, filters?: {
+ status?: string[];
+ phase?: string[];
+ category?: string[];
+}, tasksDir: string = "docs/_tasks"): Promise {
+ const tasks = await getTaskFiles(tasksDir);
+
+ return tasks.filter(task => {
+ // Text search in title and content
+ const matchesQuery = !query ||
+ task.title.toLowerCase().includes(query.toLowerCase()) ||
+ task.content.toLowerCase().includes(query.toLowerCase());
+
+ // Filter by status
+ const matchesStatus = !filters?.status ||
+ filters.status.includes(task.status);
+
+ // Filter by phase
+ const matchesPhase = !filters?.phase ||
+ filters.phase.includes(task.phase || '');
+
+ // Filter by category
+ const matchesCategory = !filters?.category ||
+ filters.category.includes(task.category || '');
+
+ return matchesQuery && matchesStatus && matchesPhase && matchesCategory;
+ });
}
\ No newline at end of file