mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
303 lines • 12.7 kB
JavaScript
// analyze.js - POST /api/bundles/analyze
import { Router } from 'express';
import { success, error, asyncHandler } from '../../utils/response.js';
import { query as dbAll, get as dbGet } from '../../utils/database.js';
const router = Router();
// Analyze tasks for bundling opportunities
router.post('/api/bundles/analyze', asyncHandler(async (req, res) => {
const { project, agent // Optional: agent name to tailor bundles to their model
} = req.body;
if (!project) {
const errorResponse = error('Project is required', 400, {
recovery: {
immediate_actions: ['Provide a valid project name'],
examples: { correct_usage: { project: 'my-app' } }
}
});
return res.status(errorResponse.status).json(errorResponse.body);
}
try {
// Get agent's model if provided
let model = 'sonnet';
if (agent) {
const agentInfo = await dbGet('SELECT model FROM agents WHERE name = ? LIMIT 1', [agent]);
if (agentInfo?.model) {
model = agentInfo.model;
}
}
// Get all ready, unbundled tasks
const unbundledTasks = await dbAll(`
SELECT t.*
FROM tasks t
WHERE t.project = ?
AND t.status = 'ready'
AND t.id NOT IN (
SELECT bt.task_id
FROM bundle_tasks bt
JOIN task_bundles tb ON bt.bundle_id = tb.id
WHERE tb.status IN ('proposed', 'active', 'claimed', 'in_progress')
)
ORDER BY
CASE t.priority
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
ELSE 4
END,
t.created_at
`, [project]);
if (unbundledTasks.length === 0) {
return res.json(success({
project,
agent,
bundle_count: 0,
bundles: [],
message: 'No unbundled ready tasks available'
}));
}
// Extract semantic prefixes from task descriptions
const tasksWithPrefixes = unbundledTasks.map(task => {
let semanticPrefix = 'MISC';
let fullPrefix = 'MISC';
const match = task.description.match(/^\[([^\]]+)\]/);
if (match) {
fullPrefix = match[1];
// Extract semantic part (before last dash and number)
if (fullPrefix.match(/^[A-Z]+-[A-Z]+-\d+$/)) {
// Format: AREA-FEATURE-###
semanticPrefix = fullPrefix.substring(0, fullPrefix.lastIndexOf('-'));
}
else if (fullPrefix.match(/^[A-Z]+-\d+$/)) {
// Format: AREA-###
semanticPrefix = fullPrefix.substring(0, fullPrefix.indexOf('-'));
}
else {
semanticPrefix = fullPrefix;
}
}
return {
...task,
semantic_prefix: semanticPrefix,
full_prefix: fullPrefix
};
});
// Find tasks that would unlock others
const unlockData = await dbAll(`
SELECT
t.id,
COUNT(DISTINCT td.task_id) as unlocks_count,
GROUP_CONCAT(blocked.id) as unlocks_ids
FROM tasks t
JOIN task_dependencies td ON td.depends_on = t.id
JOIN tasks blocked ON blocked.id = td.task_id AND blocked.status = 'blocked'
WHERE t.project = ?
AND t.status = 'ready'
AND t.id IN (${unbundledTasks.map(() => '?').join(',')})
GROUP BY t.id
`, [project, ...unbundledTasks.map(t => t.id)]);
const unlockMap = new Map(unlockData.map(u => [u.id, u]));
// Create bundle candidates
const bundleCandidates = [];
const processedTasks = new Set();
// 1. Unlock chain bundles (highest priority)
for (const task of tasksWithPrefixes) {
if (processedTasks.has(task.id))
continue;
const unlockInfo = unlockMap.get(task.id);
if (unlockInfo && unlockInfo.unlocks_count > 0) {
bundleCandidates.push({
bundle_type: 'unlock_chain',
priority_score: 100 + (unlockInfo.unlocks_count * 10),
task_ids: [task.id],
task_count: 1,
total_hours: task.estimated_hours || 2,
reason: `Unlocks ${unlockInfo.unlocks_count} blocked tasks`,
recommendation: 'High priority - removes blockers',
semantic_prefix: task.semantic_prefix
});
processedTasks.add(task.id);
}
}
// 2. Semantic prefix bundles
const prefixGroups = new Map();
for (const task of tasksWithPrefixes) {
if (processedTasks.has(task.id))
continue;
if (task.semantic_prefix === 'MISC')
continue;
if (!prefixGroups.has(task.semantic_prefix)) {
prefixGroups.set(task.semantic_prefix, []);
}
prefixGroups.get(task.semantic_prefix).push(task);
}
// Model-specific bundle constraints
const minTasks = model === 'opus' ? 3 : 2;
const maxHours = model === 'opus' ? 16 : 8;
for (const [prefix, tasks] of prefixGroups) {
if (tasks.length >= minTasks) {
// Sort by task number within prefix
tasks.sort((a, b) => a.full_prefix.localeCompare(b.full_prefix));
// Create bundles respecting hour limits
let currentBundle = [];
let currentHours = 0;
for (const task of tasks) {
const taskHours = task.estimated_hours || 2;
if (currentBundle.length > 0 &&
(currentHours + taskHours > maxHours || currentBundle.length >= 6)) {
// Save current bundle
if (currentBundle.length >= minTasks) {
bundleCandidates.push({
bundle_type: 'semantic_group',
priority_score: 70 + currentBundle.length,
task_ids: currentBundle.map(t => t.id),
task_count: currentBundle.length,
total_hours: currentHours,
reason: `Related ${prefix} tasks`,
recommendation: 'Efficient to complete together',
semantic_prefix: prefix
});
currentBundle.forEach(t => processedTasks.add(t.id));
}
// Start new bundle
currentBundle = [task];
currentHours = taskHours;
}
else {
currentBundle.push(task);
currentHours += taskHours;
}
}
// Save final bundle
if (currentBundle.length >= minTasks) {
bundleCandidates.push({
bundle_type: 'semantic_group',
priority_score: 70 + currentBundle.length,
task_ids: currentBundle.map(t => t.id),
task_count: currentBundle.length,
total_hours: currentHours,
reason: `Related ${prefix} tasks`,
recommendation: 'Efficient to complete together',
semantic_prefix: prefix
});
currentBundle.forEach(t => processedTasks.add(t.id));
}
}
}
// 3. Category bundles for remaining tasks
const categoryGroups = new Map();
for (const task of tasksWithPrefixes) {
if (processedTasks.has(task.id))
continue;
if (!task.category)
continue;
if (!categoryGroups.has(task.category)) {
categoryGroups.set(task.category, []);
}
categoryGroups.get(task.category).push(task);
}
for (const [category, tasks] of categoryGroups) {
if (tasks.length >= minTasks) {
const totalHours = tasks.reduce((sum, t) => sum + (t.estimated_hours || 2), 0);
if (totalHours <= maxHours) {
bundleCandidates.push({
bundle_type: 'category_group',
priority_score: 50 + tasks.length,
task_ids: tasks.map(t => t.id),
task_count: tasks.length,
total_hours: totalHours,
reason: `Same category: ${category}`,
recommendation: 'Similar work type',
semantic_prefix: category
});
}
}
}
// Sort by priority score
bundleCandidates.sort((a, b) => b.priority_score - a.priority_score);
// Add task details to bundles
const enrichedBundles = bundleCandidates.map(bundle => {
const tasks = bundle.task_ids.map(id => tasksWithPrefixes.find(t => t.id === id));
return {
...bundle,
tasks: tasks.map(t => ({
id: t.id,
description: t.description,
priority: t.priority,
estimated_hours: t.estimated_hours
}))
};
});
// OPUS enhancement: include unbundled tasks analysis
let response;
if (model === 'opus') {
const unbundledRemaining = tasksWithPrefixes.filter(t => !processedTasks.has(t.id));
response = success({
project,
agent,
model,
bundle_count: enrichedBundles.length,
bundles: enrichedBundles,
opus_enhanced: true,
unbundled_tasks: {
count: unbundledRemaining.length,
tasks: unbundledRemaining.map(t => ({
id: t.id,
description: t.description,
priority: t.priority,
estimated_hours: t.estimated_hours,
semantic_prefix: t.semantic_prefix
})),
recommendation: unbundledRemaining.length > 0
? 'OPUS agent should review unbundled tasks and create optimal bundles'
: 'All tasks efficiently bundled'
}
});
}
else {
response = success({
project,
agent,
model,
bundle_count: enrichedBundles.length,
bundles: enrichedBundles
});
}
res.json(response);
}
catch (err) {
console.error('Failed to analyze bundles:', err);
const errorResponse = error('Failed to analyze bundles', 500, {
error: err.message,
recovery: {
immediate_actions: [
'Check database connection',
'Verify project exists',
'Check for valid task data'
]
}
});
res.status(errorResponse.status).json(errorResponse.body);
}
}));
// MCP tool definition
export const tool = {
name: 'bundle_analyze',
description: 'Analyze ready, unbundled tasks for bundling opportunities. Returns prioritized bundle suggestions based on: unlock chains (highest priority), semantic prefixes, and categories. OPUS agents receive enhanced analysis including remaining unbundled tasks.',
inputSchema: {
type: 'object',
properties: {
project: {
type: 'string',
description: 'Project name to analyze'
},
agent: {
type: 'string',
description: 'Optional: Agent name for model-aware bundling. OPUS agents get enhanced analysis and can handle larger bundles (3-8 tasks, 16 hours) vs SONNET (2-6 tasks, 8 hours).'
}
},
required: ['project']
}
};
export { router };
export default router;
//# sourceMappingURL=analyze.js.map