@elixium.ai/mcp-server 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +676 -289
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -164,19 +164,91 @@ const resolveBoardId = async () => {
|
|
|
164
164
|
cachedBoardId = match.id;
|
|
165
165
|
return cachedBoardId;
|
|
166
166
|
};
|
|
167
|
+
// Feature flag caching
|
|
168
|
+
let cachedFeatureConfig = null;
|
|
169
|
+
const FEATURE_FLAG_CACHE_TTL = 60000; // 1 minute
|
|
170
|
+
const fetchFeatureConfig = async () => {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
if (cachedFeatureConfig && now - cachedFeatureConfig.fetchedAt < FEATURE_FLAG_CACHE_TTL) {
|
|
173
|
+
const { fetchedAt, ...config } = cachedFeatureConfig;
|
|
174
|
+
return config;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const boardId = await resolveBoardId();
|
|
178
|
+
const response = await client.get("/config/features", {
|
|
179
|
+
params: boardId ? { boardId } : undefined,
|
|
180
|
+
});
|
|
181
|
+
cachedFeatureConfig = {
|
|
182
|
+
...response.data,
|
|
183
|
+
fetchedAt: now,
|
|
184
|
+
};
|
|
185
|
+
return response.data;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error("Failed to fetch feature config, defaulting to all enabled:", error);
|
|
189
|
+
return {
|
|
190
|
+
features: {
|
|
191
|
+
balancedTeam: true,
|
|
192
|
+
learningLoop: true,
|
|
193
|
+
tddWorkflow: true,
|
|
194
|
+
aiTools: true,
|
|
195
|
+
},
|
|
196
|
+
source: {
|
|
197
|
+
balancedTeam: "error-fallback",
|
|
198
|
+
learningLoop: "error-fallback",
|
|
199
|
+
tddWorkflow: "error-fallback",
|
|
200
|
+
aiTools: "error-fallback",
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
// Helper functions to check individual features
|
|
206
|
+
const isTddWorkflowEnabled = async () => {
|
|
207
|
+
const config = await fetchFeatureConfig();
|
|
208
|
+
return config.features.tddWorkflow;
|
|
209
|
+
};
|
|
210
|
+
const isLearningLoopEnabled = async () => {
|
|
211
|
+
const config = await fetchFeatureConfig();
|
|
212
|
+
return config.features.learningLoop;
|
|
213
|
+
};
|
|
214
|
+
const isAiToolsEnabled = async () => {
|
|
215
|
+
const config = await fetchFeatureConfig();
|
|
216
|
+
return config.features.aiTools;
|
|
217
|
+
};
|
|
218
|
+
const getTeamProfile = async () => {
|
|
219
|
+
const config = await fetchFeatureConfig();
|
|
220
|
+
return config.teamProfile;
|
|
221
|
+
};
|
|
167
222
|
const fetchStories = async () => {
|
|
168
223
|
const boardId = await resolveBoardId();
|
|
224
|
+
const slug = normalizeBoardSlug(BOARD_SLUG);
|
|
225
|
+
const isMainBoard = slug === "main";
|
|
226
|
+
// For the "main" board, fetch ALL stories (no boardId filter) then filter
|
|
227
|
+
// client-side. This matches the frontend behavior: legacy stories have
|
|
228
|
+
// boardId: null and must be included on the main board.
|
|
169
229
|
const response = await client.get("/stories", {
|
|
170
|
-
params: boardId ? { boardId } : undefined,
|
|
230
|
+
params: boardId && !isMainBoard ? { boardId } : undefined,
|
|
171
231
|
});
|
|
172
|
-
|
|
232
|
+
let stories = extractStories(response.data);
|
|
233
|
+
// Client-side filter for main board: include stories matching boardId OR
|
|
234
|
+
// with no boardId (legacy stories created before multi-board support)
|
|
235
|
+
if (boardId && isMainBoard) {
|
|
236
|
+
stories = stories.filter((s) => s.boardId === boardId || !s.boardId);
|
|
237
|
+
}
|
|
238
|
+
return stories;
|
|
173
239
|
};
|
|
174
240
|
const fetchEpics = async () => {
|
|
175
241
|
const boardId = await resolveBoardId();
|
|
242
|
+
const slug = normalizeBoardSlug(BOARD_SLUG);
|
|
243
|
+
const isMainBoard = slug === "main";
|
|
176
244
|
const response = await client.get("/epics", {
|
|
177
|
-
params: boardId ? { boardId } : undefined,
|
|
245
|
+
params: boardId && !isMainBoard ? { boardId } : undefined,
|
|
178
246
|
});
|
|
179
|
-
|
|
247
|
+
let epics = extractEpics(response.data);
|
|
248
|
+
if (boardId && isMainBoard) {
|
|
249
|
+
epics = epics.filter((e) => e.boardId === boardId || !e.boardId);
|
|
250
|
+
}
|
|
251
|
+
return epics;
|
|
180
252
|
};
|
|
181
253
|
let cachedLaneStyle = null;
|
|
182
254
|
const detectLaneStyle = async () => {
|
|
@@ -214,7 +286,15 @@ const normalizeLane = async (lane) => {
|
|
|
214
286
|
const buildIterationContext = (stories, user = null) => {
|
|
215
287
|
const currentIteration = stories.filter((story) => normalizeLaneForComparison(story?.lane) === "current");
|
|
216
288
|
const backlog = stories.filter((story) => normalizeLaneForComparison(story?.lane) === "backlog");
|
|
217
|
-
|
|
289
|
+
// Cost summary across current iteration
|
|
290
|
+
const iterationStories = [...currentIteration, ...backlog];
|
|
291
|
+
const withEstimates = iterationStories.filter((s) => s.costEstimate);
|
|
292
|
+
const costSummary = {
|
|
293
|
+
totalMonthlyCost: withEstimates.reduce((sum, s) => sum + (s.costEstimate?.totalMonthlyCost || 0), 0),
|
|
294
|
+
estimatedCount: withEstimates.length,
|
|
295
|
+
unestimatedCount: iterationStories.length - withEstimates.length,
|
|
296
|
+
};
|
|
297
|
+
return { currentIteration, backlog, user, costSummary };
|
|
218
298
|
};
|
|
219
299
|
const createServer = () => {
|
|
220
300
|
const server = new Server({
|
|
@@ -227,302 +307,432 @@ const createServer = () => {
|
|
|
227
307
|
});
|
|
228
308
|
// Define Tools
|
|
229
309
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
310
|
+
const featureConfig = await fetchFeatureConfig();
|
|
311
|
+
const tddEnabled = featureConfig.features.tddWorkflow;
|
|
312
|
+
const learningLoopEnabled = featureConfig.features.learningLoop;
|
|
313
|
+
const baseTools = [
|
|
314
|
+
{
|
|
315
|
+
name: "get_feature_config",
|
|
316
|
+
description: "Get the feature configuration for this board/workspace. Returns enabled features, team profile, and smart defaults context.",
|
|
317
|
+
inputSchema: {
|
|
318
|
+
type: "object",
|
|
319
|
+
properties: {},
|
|
239
320
|
},
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
description: {
|
|
248
|
-
type: "string",
|
|
249
|
-
description: "Description of the story",
|
|
250
|
-
},
|
|
251
|
-
acceptanceCriteria: {
|
|
252
|
-
type: "string",
|
|
253
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
254
|
-
},
|
|
255
|
-
lane: {
|
|
256
|
-
type: "string",
|
|
257
|
-
description: "Lane to add the story to (case-insensitive)",
|
|
258
|
-
enum: [
|
|
259
|
-
"Backlog",
|
|
260
|
-
"Icebox",
|
|
261
|
-
"Current",
|
|
262
|
-
"Done",
|
|
263
|
-
"BACKLOG",
|
|
264
|
-
"ICEBOX",
|
|
265
|
-
"CURRENT",
|
|
266
|
-
"DONE",
|
|
267
|
-
],
|
|
268
|
-
},
|
|
269
|
-
points: {
|
|
270
|
-
type: "number",
|
|
271
|
-
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
272
|
-
},
|
|
273
|
-
requester: {
|
|
274
|
-
type: "string",
|
|
275
|
-
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
276
|
-
},
|
|
277
|
-
},
|
|
278
|
-
required: ["title"],
|
|
279
|
-
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "list_stories",
|
|
324
|
+
description: "List all stories on the Elixium board",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: {},
|
|
280
328
|
},
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "create_story",
|
|
332
|
+
description: "Create a new story on the Elixium board",
|
|
333
|
+
inputSchema: {
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: {
|
|
336
|
+
title: { type: "string", description: "Title of the story" },
|
|
337
|
+
description: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "Description of the story",
|
|
340
|
+
},
|
|
341
|
+
acceptanceCriteria: {
|
|
342
|
+
type: "string",
|
|
343
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
344
|
+
},
|
|
345
|
+
lane: {
|
|
346
|
+
type: "string",
|
|
347
|
+
description: "Lane to add the story to (case-insensitive)",
|
|
348
|
+
enum: [
|
|
349
|
+
"Backlog",
|
|
350
|
+
"Icebox",
|
|
351
|
+
"Current",
|
|
352
|
+
"Done",
|
|
353
|
+
"BACKLOG",
|
|
354
|
+
"ICEBOX",
|
|
355
|
+
"CURRENT",
|
|
356
|
+
"DONE",
|
|
357
|
+
],
|
|
358
|
+
},
|
|
359
|
+
points: {
|
|
360
|
+
type: "number",
|
|
361
|
+
description: "Points (0, 1, 2, 3, 5, 8)",
|
|
362
|
+
},
|
|
363
|
+
requester: {
|
|
364
|
+
type: "string",
|
|
365
|
+
description: "Email of the person requesting this story. Defaults to ELIXIUM_USER_EMAIL env var or API key owner.",
|
|
366
|
+
},
|
|
287
367
|
},
|
|
368
|
+
required: ["title"],
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: "get_iteration_context",
|
|
373
|
+
description: "Get the current iteration context (Current + Backlog) for AI planning",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "list_objectives",
|
|
381
|
+
description: "List objectives for the current workspace",
|
|
382
|
+
inputSchema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {},
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: "list_epics",
|
|
389
|
+
description: "List epics for the current board",
|
|
390
|
+
inputSchema: {
|
|
391
|
+
type: "object",
|
|
392
|
+
properties: {},
|
|
288
393
|
},
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
},
|
|
304
|
-
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: "create_epic",
|
|
397
|
+
description: "Create a new epic for the current board",
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: {
|
|
401
|
+
title: { type: "string", description: "Epic title" },
|
|
402
|
+
description: { type: "string", description: "Epic description" },
|
|
403
|
+
stage: {
|
|
404
|
+
type: "string",
|
|
405
|
+
description: "Roadmap stage",
|
|
406
|
+
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
407
|
+
},
|
|
408
|
+
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
409
|
+
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
410
|
+
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
411
|
+
outcomeStatus: {
|
|
412
|
+
type: "string",
|
|
413
|
+
description: "Outcome validation status",
|
|
414
|
+
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
415
|
+
},
|
|
305
416
|
},
|
|
417
|
+
required: ["title"],
|
|
306
418
|
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: "update_epic",
|
|
422
|
+
description: "Update an epic (title, description, stage, or outcome fields)",
|
|
423
|
+
inputSchema: {
|
|
424
|
+
type: "object",
|
|
425
|
+
properties: {
|
|
426
|
+
epicId: { type: "string", description: "ID of the epic" },
|
|
427
|
+
title: { type: "string", description: "Epic title" },
|
|
428
|
+
description: { type: "string", description: "Epic description" },
|
|
429
|
+
stage: {
|
|
430
|
+
type: "string",
|
|
431
|
+
description: "Roadmap stage",
|
|
432
|
+
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
433
|
+
},
|
|
434
|
+
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
435
|
+
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
436
|
+
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
437
|
+
outcomeStatus: {
|
|
438
|
+
type: "string",
|
|
439
|
+
description: "Outcome validation status",
|
|
440
|
+
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
441
|
+
},
|
|
313
442
|
},
|
|
443
|
+
required: ["epicId"],
|
|
314
444
|
},
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "update_story",
|
|
448
|
+
description: "Update fields on an existing story",
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: "object",
|
|
451
|
+
properties: {
|
|
452
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
453
|
+
title: { type: "string", description: "Updated title" },
|
|
454
|
+
description: { type: "string", description: "Updated description" },
|
|
455
|
+
lane: {
|
|
456
|
+
type: "string",
|
|
457
|
+
description: "Lane to move the story to (case-insensitive)",
|
|
458
|
+
enum: [
|
|
459
|
+
"Backlog",
|
|
460
|
+
"Icebox",
|
|
461
|
+
"Current",
|
|
462
|
+
"Done",
|
|
463
|
+
"BACKLOG",
|
|
464
|
+
"ICEBOX",
|
|
465
|
+
"CURRENT",
|
|
466
|
+
"DONE",
|
|
467
|
+
],
|
|
468
|
+
},
|
|
469
|
+
points: {
|
|
470
|
+
type: "number",
|
|
471
|
+
description: "Updated points (0, 1, 2, 3, 5, 8)",
|
|
472
|
+
},
|
|
473
|
+
state: {
|
|
474
|
+
type: "string",
|
|
475
|
+
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
476
|
+
},
|
|
477
|
+
outcome_summary: {
|
|
478
|
+
type: "string",
|
|
479
|
+
description: "Learning outcome summary",
|
|
480
|
+
},
|
|
481
|
+
acceptanceCriteria: {
|
|
482
|
+
type: "string",
|
|
483
|
+
description: "Acceptance criteria in Given/When/Then format",
|
|
484
|
+
},
|
|
328
485
|
},
|
|
486
|
+
required: ["storyId"],
|
|
329
487
|
},
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "prepare_implementation",
|
|
491
|
+
description: "Fetch all context for a story and format an implementation brief",
|
|
492
|
+
inputSchema: {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
storyId: {
|
|
496
|
+
type: "string",
|
|
497
|
+
description: "ID of the story to prepare",
|
|
498
|
+
},
|
|
336
499
|
},
|
|
500
|
+
required: ["storyId"],
|
|
337
501
|
},
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
},
|
|
359
|
-
},
|
|
360
|
-
required: ["title"],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "estimate_cost",
|
|
505
|
+
description: "Generate AI-assisted infrastructure cost estimate for a story. Uses live cloud pricing data when available.",
|
|
506
|
+
inputSchema: {
|
|
507
|
+
type: "object",
|
|
508
|
+
properties: {
|
|
509
|
+
storyId: {
|
|
510
|
+
type: "string",
|
|
511
|
+
description: "ID of the story to estimate cost for",
|
|
512
|
+
},
|
|
513
|
+
provider: {
|
|
514
|
+
type: "string",
|
|
515
|
+
description: "Cloud provider for the estimate",
|
|
516
|
+
enum: ["gcp", "aws", "azure", "self-hosted"],
|
|
517
|
+
},
|
|
518
|
+
constraints: {
|
|
519
|
+
type: "string",
|
|
520
|
+
description: "Optional constraints (e.g., budget limit, region, instance types)",
|
|
521
|
+
},
|
|
361
522
|
},
|
|
523
|
+
required: ["storyId", "provider"],
|
|
362
524
|
},
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
description: "Roadmap stage",
|
|
375
|
-
enum: ["in_progress", "next", "soon", "someday", "archived"],
|
|
376
|
-
},
|
|
377
|
-
hypothesis: { type: "string", description: "We believe that... (outcome hypothesis)" },
|
|
378
|
-
successMetrics: { type: "string", description: "We'll know it works when... (success criteria)" },
|
|
379
|
-
targetDate: { type: "number", description: "Unix timestamp - when to evaluate outcome (not ship date)" },
|
|
380
|
-
outcomeStatus: {
|
|
381
|
-
type: "string",
|
|
382
|
-
description: "Outcome validation status",
|
|
383
|
-
enum: ["exploring", "validating", "achieved", "abandoned"],
|
|
384
|
-
},
|
|
385
|
-
},
|
|
386
|
-
required: ["epicId"],
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "get_epic_cost_rollup",
|
|
528
|
+
description: "Get aggregated infrastructure cost for all stories in an epic. Shows total monthly cost, category breakdown, and estimation coverage.",
|
|
529
|
+
inputSchema: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
epicId: {
|
|
533
|
+
type: "string",
|
|
534
|
+
description: "ID of the epic",
|
|
535
|
+
},
|
|
387
536
|
},
|
|
537
|
+
required: ["epicId"],
|
|
388
538
|
},
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
state: {
|
|
417
|
-
type: "string",
|
|
418
|
-
description: "Story state (unstarted, started, finished, delivered, accepted, rejected)",
|
|
419
|
-
},
|
|
420
|
-
outcome_summary: {
|
|
421
|
-
type: "string",
|
|
422
|
-
description: "Learning outcome summary",
|
|
423
|
-
},
|
|
424
|
-
acceptanceCriteria: {
|
|
425
|
-
type: "string",
|
|
426
|
-
description: "Acceptance criteria in Given/When/Then format",
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
required: ["storyId"],
|
|
539
|
+
},
|
|
540
|
+
];
|
|
541
|
+
const tddTools = tddEnabled ? [
|
|
542
|
+
// TDD Workflow Tools
|
|
543
|
+
{
|
|
544
|
+
name: "start_story",
|
|
545
|
+
description: "Start TDD workflow for a story. By default creates a feature branch. Set trunkBased=true to work directly on main (recommended for small changes). Set autoMerge=true with trunkBased for short-lived branches that auto-merge on submit.",
|
|
546
|
+
inputSchema: {
|
|
547
|
+
type: "object",
|
|
548
|
+
properties: {
|
|
549
|
+
storyId: {
|
|
550
|
+
type: "string",
|
|
551
|
+
description: "ID of the story to start",
|
|
552
|
+
},
|
|
553
|
+
branchPrefix: {
|
|
554
|
+
type: "string",
|
|
555
|
+
description: "Branch prefix (feat, fix, chore) - ignored if trunkBased=true without autoMerge",
|
|
556
|
+
enum: ["feat", "fix", "chore"],
|
|
557
|
+
},
|
|
558
|
+
trunkBased: {
|
|
559
|
+
type: "boolean",
|
|
560
|
+
description: "If true, use trunk-based development. Without autoMerge, commits directly to main with feature flag. With autoMerge, creates a short-lived branch. Board/workspace defaults apply if not set.",
|
|
561
|
+
},
|
|
562
|
+
autoMerge: {
|
|
563
|
+
type: "boolean",
|
|
564
|
+
description: "If true with trunkBased, creates a short-lived branch that auto-merges to main on submit_for_review. Branch is deleted after merge.",
|
|
565
|
+
},
|
|
430
566
|
},
|
|
567
|
+
required: ["storyId"],
|
|
431
568
|
},
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
},
|
|
443
|
-
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "propose_test_plan",
|
|
572
|
+
description: "Submit a test plan for human review. Sets workflow_stage to tests_proposed. Implementation is BLOCKED until human approves.",
|
|
573
|
+
inputSchema: {
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
storyId: {
|
|
577
|
+
type: "string",
|
|
578
|
+
description: "ID of the story",
|
|
579
|
+
},
|
|
580
|
+
testPlan: {
|
|
581
|
+
type: "string",
|
|
582
|
+
description: "Markdown test plan describing test strategy",
|
|
583
|
+
},
|
|
584
|
+
testFilePaths: {
|
|
585
|
+
type: "array",
|
|
586
|
+
items: { type: "string" },
|
|
587
|
+
description: "Paths to created test files",
|
|
588
|
+
},
|
|
444
589
|
},
|
|
590
|
+
required: ["storyId", "testPlan"],
|
|
445
591
|
},
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "submit_for_review",
|
|
595
|
+
description: "Submit implementation for human review. Only works if tests are approved. Sets state to finished.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
type: "object",
|
|
598
|
+
properties: {
|
|
599
|
+
storyId: {
|
|
600
|
+
type: "string",
|
|
601
|
+
description: "ID of the story",
|
|
602
|
+
},
|
|
603
|
+
commitHash: {
|
|
604
|
+
type: "string",
|
|
605
|
+
description: "Latest commit SHA",
|
|
606
|
+
},
|
|
607
|
+
testResults: {
|
|
608
|
+
type: "string",
|
|
609
|
+
description: "Test output summary",
|
|
610
|
+
},
|
|
611
|
+
implementationNotes: {
|
|
612
|
+
type: "string",
|
|
613
|
+
description: "What was implemented",
|
|
614
|
+
},
|
|
468
615
|
},
|
|
616
|
+
required: ["storyId"],
|
|
469
617
|
},
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
},
|
|
489
|
-
},
|
|
490
|
-
required: ["storyId", "testPlan"],
|
|
618
|
+
},
|
|
619
|
+
] : [];
|
|
620
|
+
// Learning Loop tools (conditional on feature flag)
|
|
621
|
+
const learningLoopTools = learningLoopEnabled ? [
|
|
622
|
+
{
|
|
623
|
+
name: "create_hypothesis",
|
|
624
|
+
description: "Create a new assumption/hypothesis in the Icebox for validation",
|
|
625
|
+
inputSchema: {
|
|
626
|
+
type: "object",
|
|
627
|
+
properties: {
|
|
628
|
+
title: { type: "string", description: "The assumption statement" },
|
|
629
|
+
hypothesis: { type: "string", description: "Detailed hypothesis" },
|
|
630
|
+
confidence_score: {
|
|
631
|
+
type: "number",
|
|
632
|
+
description: "Initial confidence (1-5)",
|
|
633
|
+
minimum: 1,
|
|
634
|
+
maximum: 5,
|
|
635
|
+
},
|
|
491
636
|
},
|
|
637
|
+
required: ["title", "hypothesis", "confidence_score"],
|
|
492
638
|
},
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
description: "Latest commit SHA",
|
|
506
|
-
},
|
|
507
|
-
testResults: {
|
|
508
|
-
type: "string",
|
|
509
|
-
description: "Test output summary",
|
|
510
|
-
},
|
|
511
|
-
implementationNotes: {
|
|
512
|
-
type: "string",
|
|
513
|
-
description: "What was implemented",
|
|
514
|
-
},
|
|
515
|
-
},
|
|
516
|
-
required: ["storyId"],
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
name: "record_learning",
|
|
642
|
+
description: "Record a learning outcome for a completed story",
|
|
643
|
+
inputSchema: {
|
|
644
|
+
type: "object",
|
|
645
|
+
properties: {
|
|
646
|
+
storyId: { type: "string", description: "ID of the story" },
|
|
647
|
+
outcome_summary: {
|
|
648
|
+
type: "string",
|
|
649
|
+
description: "What was learned?",
|
|
650
|
+
},
|
|
517
651
|
},
|
|
652
|
+
required: ["storyId", "outcome_summary"],
|
|
518
653
|
},
|
|
519
|
-
|
|
520
|
-
|
|
654
|
+
},
|
|
655
|
+
] : [];
|
|
656
|
+
return { tools: [...baseTools, ...tddTools, ...learningLoopTools] };
|
|
521
657
|
});
|
|
522
658
|
// Handle Requests
|
|
523
659
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
524
660
|
try {
|
|
525
|
-
|
|
661
|
+
const toolName = request.params.name;
|
|
662
|
+
// Check TDD workflow tools
|
|
663
|
+
const tddWorkflowTools = ["start_story", "propose_test_plan", "submit_for_review"];
|
|
664
|
+
if (tddWorkflowTools.includes(toolName)) {
|
|
665
|
+
const enabled = await isTddWorkflowEnabled();
|
|
666
|
+
if (!enabled) {
|
|
667
|
+
return {
|
|
668
|
+
content: [{
|
|
669
|
+
type: "text",
|
|
670
|
+
text: JSON.stringify({
|
|
671
|
+
error: "TDD workflow is disabled",
|
|
672
|
+
message: "The TDD workflow feature is currently disabled for this board. Contact your workspace administrator to enable it.",
|
|
673
|
+
help: "TDD workflow can be enabled in workspace settings or per-board settings."
|
|
674
|
+
}, null, 2)
|
|
675
|
+
}],
|
|
676
|
+
isError: true,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Check Learning Loop tools
|
|
681
|
+
const learningLoopTools = ["create_hypothesis", "record_learning"];
|
|
682
|
+
if (learningLoopTools.includes(toolName)) {
|
|
683
|
+
const enabled = await isLearningLoopEnabled();
|
|
684
|
+
if (!enabled) {
|
|
685
|
+
return {
|
|
686
|
+
content: [{
|
|
687
|
+
type: "text",
|
|
688
|
+
text: JSON.stringify({
|
|
689
|
+
error: "Learning Loop is disabled",
|
|
690
|
+
message: "The Learning Loop feature is currently disabled for this board. This feature includes hypothesis tracking and confidence scoring.",
|
|
691
|
+
help: "Learning Loop can be enabled in workspace settings or per-board settings."
|
|
692
|
+
}, null, 2)
|
|
693
|
+
}],
|
|
694
|
+
isError: true,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
switch (toolName) {
|
|
699
|
+
case "get_feature_config": {
|
|
700
|
+
const config = await fetchFeatureConfig();
|
|
701
|
+
const formattedConfig = `
|
|
702
|
+
# Workspace Feature Configuration
|
|
703
|
+
|
|
704
|
+
## Enabled Features
|
|
705
|
+
${config.features.balancedTeam ? "✅" : "❌"} **Balanced Team** - Design URLs, Three Amigos checks, OKR linking
|
|
706
|
+
${config.features.learningLoop ? "✅" : "❌"} **Learning Loop** - Hypothesis tracking, risk profiles, confidence scores
|
|
707
|
+
${config.features.tddWorkflow ? "✅" : "❌"} **TDD Workflow** - Test-driven development enforcement
|
|
708
|
+
${config.features.aiTools ? "✅" : "❌"} **AI Tools** - AI-powered suggestions and analysis
|
|
709
|
+
|
|
710
|
+
## Team Profile
|
|
711
|
+
${config.teamProfile ? `
|
|
712
|
+
- Team Size: ${config.teamProfile.teamSize || "Not set"}
|
|
713
|
+
- Has Designer: ${config.teamProfile.hasDesigner ? "Yes" : "No"}
|
|
714
|
+
- Has Product Manager: ${config.teamProfile.hasProductManager ? "Yes" : "No"}
|
|
715
|
+
- Has QA: ${config.teamProfile.hasQA ? "Yes" : "No"}
|
|
716
|
+
- Development Approach: ${config.teamProfile.developmentApproach || "Not set"}
|
|
717
|
+
` : "Team profile not configured"}
|
|
718
|
+
|
|
719
|
+
## Feature Sources
|
|
720
|
+
- Balanced Team: ${config.source.balancedTeam}
|
|
721
|
+
- Learning Loop: ${config.source.learningLoop}
|
|
722
|
+
- TDD Workflow: ${config.source.tddWorkflow}
|
|
723
|
+
- AI Tools: ${config.source.aiTools}
|
|
724
|
+
|
|
725
|
+
## Branching Strategy Defaults
|
|
726
|
+
${config.branchingDefaults ? `- Trunk-Based: ${config.branchingDefaults.trunkBased ? "Yes" : "No"}
|
|
727
|
+
- Auto-Merge: ${config.branchingDefaults.autoMerge ? "Yes" : "No"}
|
|
728
|
+
- Source: ${config.branchingDefaults.source}` : "Not configured (branch-based default)"}
|
|
729
|
+
|
|
730
|
+
> **Tip:** Features can be configured at workspace or board level. Board settings override workspace defaults.
|
|
731
|
+
`;
|
|
732
|
+
return {
|
|
733
|
+
content: [{ type: "text", text: formattedConfig.trim() }],
|
|
734
|
+
};
|
|
735
|
+
}
|
|
526
736
|
case "list_stories": {
|
|
527
737
|
const stories = await fetchStories();
|
|
528
738
|
return {
|
|
@@ -723,35 +933,68 @@ Here’s the smallest change that will validate it:
|
|
|
723
933
|
// TDD Workflow Handlers
|
|
724
934
|
case "start_story": {
|
|
725
935
|
const args = request.params.arguments;
|
|
726
|
-
const { storyId, branchPrefix, trunkBased } = args;
|
|
936
|
+
const { storyId, branchPrefix, trunkBased, autoMerge } = args;
|
|
727
937
|
if (!storyId) {
|
|
728
938
|
throw new Error("storyId is required");
|
|
729
939
|
}
|
|
730
940
|
const response = await client.post(`/stories/${storyId}/start`, {
|
|
731
941
|
branchPrefix: branchPrefix || "feat",
|
|
732
|
-
trunkBased
|
|
942
|
+
trunkBased,
|
|
943
|
+
autoMerge,
|
|
733
944
|
});
|
|
734
945
|
const result = response.data;
|
|
735
|
-
const isTrunk =
|
|
736
|
-
const
|
|
946
|
+
const isTrunk = result.trunkBased;
|
|
947
|
+
const isAutoMerge = result.autoMerge;
|
|
948
|
+
let formattedResult;
|
|
949
|
+
if (isTrunk && !isAutoMerge) {
|
|
950
|
+
formattedResult = `
|
|
737
951
|
# Story Started (Trunk-Based): ${storyId}
|
|
738
952
|
|
|
739
|
-
**Mode:**
|
|
953
|
+
**Mode:** Direct to main (trunk-based development)
|
|
954
|
+
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
740
955
|
**Workflow Stage:** ${result.workflow_stage}
|
|
741
956
|
|
|
742
957
|
## Acceptance Criteria
|
|
743
958
|
${result.acceptance_criteria || "No specific AC provided."}
|
|
744
959
|
|
|
745
960
|
## Trunk-Based Workflow
|
|
746
|
-
1.
|
|
747
|
-
2.
|
|
748
|
-
3.
|
|
749
|
-
4.
|
|
750
|
-
5.
|
|
751
|
-
6.
|
|
752
|
-
|
|
753
|
-
>
|
|
754
|
-
|
|
961
|
+
1. Write tests first
|
|
962
|
+
2. Call \`propose_test_plan\` and wait for approval
|
|
963
|
+
3. Implement (make tests pass)
|
|
964
|
+
4. Run \`npm run build\` to verify
|
|
965
|
+
5. Commit directly to main behind feature flag \`${result.featureFlagName}\`
|
|
966
|
+
6. Auto-deploy on green CI
|
|
967
|
+
|
|
968
|
+
> **Feature Flag:** Wrap new behavior with \`${result.featureFlagName}\` for safe isolation.
|
|
969
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
else if (isTrunk && isAutoMerge) {
|
|
973
|
+
formattedResult = `
|
|
974
|
+
# Story Started (Trunk-Based + Auto-Merge): ${storyId}
|
|
975
|
+
|
|
976
|
+
**Mode:** Short-lived branch with auto-merge to main
|
|
977
|
+
**Branch:** \`${result.branch}\`
|
|
978
|
+
**Feature Flag:** \`${result.featureFlagName || "none"}\`
|
|
979
|
+
**Workflow Stage:** ${result.workflow_stage}
|
|
980
|
+
|
|
981
|
+
## Acceptance Criteria
|
|
982
|
+
${result.acceptance_criteria || "No specific AC provided."}
|
|
983
|
+
|
|
984
|
+
## Auto-Merge Workflow
|
|
985
|
+
1. Write tests first on branch \`${result.branch}\`
|
|
986
|
+
2. Call \`propose_test_plan\` and wait for approval
|
|
987
|
+
3. Implement (make tests pass)
|
|
988
|
+
4. Run \`npm run build\` to verify
|
|
989
|
+
5. Call \`submit_for_review\` — branch auto-merges to main if tests pass
|
|
990
|
+
6. Branch is deleted after merge
|
|
991
|
+
|
|
992
|
+
> **Feature Flag:** Wrap new behavior with \`${result.featureFlagName}\` for safe isolation.
|
|
993
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
formattedResult = `
|
|
755
998
|
# Story Started: ${storyId}
|
|
756
999
|
|
|
757
1000
|
**Branch:** \`${result.branch}\`
|
|
@@ -763,8 +1006,9 @@ ${result.acceptance_criteria || "No specific AC provided."}
|
|
|
763
1006
|
## Next Steps
|
|
764
1007
|
${result.workflow_reminder}
|
|
765
1008
|
|
|
766
|
-
>
|
|
1009
|
+
> **TDD Workflow:** Write tests first, then call \`propose_test_plan\` before implementing.
|
|
767
1010
|
`;
|
|
1011
|
+
}
|
|
768
1012
|
return {
|
|
769
1013
|
content: [{ type: "text", text: formattedResult.trim() }],
|
|
770
1014
|
};
|
|
@@ -813,6 +1057,25 @@ ${result.message}
|
|
|
813
1057
|
implementationNotes,
|
|
814
1058
|
});
|
|
815
1059
|
const result = response.data;
|
|
1060
|
+
const isAutoMerge = result.autoMerge;
|
|
1061
|
+
let autoMergeSection = "";
|
|
1062
|
+
if (isAutoMerge && result.autoMergeInstructions) {
|
|
1063
|
+
const instr = result.autoMergeInstructions;
|
|
1064
|
+
autoMergeSection = `
|
|
1065
|
+
## Auto-Merge Instructions
|
|
1066
|
+
**Action:** Merge \`${instr.sourceBranch}\` into \`${instr.targetBranch}\`
|
|
1067
|
+
**Delete branch after merge:** Yes
|
|
1068
|
+
|
|
1069
|
+
Run these git commands:
|
|
1070
|
+
\`\`\`bash
|
|
1071
|
+
git checkout main && git pull
|
|
1072
|
+
git merge ${instr.sourceBranch}
|
|
1073
|
+
git push origin main
|
|
1074
|
+
git push origin --delete ${instr.sourceBranch}
|
|
1075
|
+
git branch -d ${instr.sourceBranch}
|
|
1076
|
+
\`\`\`
|
|
1077
|
+
`;
|
|
1078
|
+
}
|
|
816
1079
|
const formattedResult = `
|
|
817
1080
|
# Implementation Submitted for Review
|
|
818
1081
|
|
|
@@ -820,17 +1083,141 @@ ${result.message}
|
|
|
820
1083
|
**Status:** ${result.status}
|
|
821
1084
|
**Workflow Stage:** ${result.workflow_stage}
|
|
822
1085
|
**State:** ${result.state}
|
|
1086
|
+
${result.trunkBased ? `**Mode:** Trunk-based${isAutoMerge ? " + Auto-merge" : ""}` : ""}
|
|
1087
|
+
${result.featureFlagName ? `**Feature Flag:** \`${result.featureFlagName}\`` : ""}
|
|
823
1088
|
|
|
824
1089
|
## Branch
|
|
825
|
-
\`${result.branch || "
|
|
1090
|
+
\`${result.branch || "main (trunk-based)"}\`
|
|
826
1091
|
|
|
827
1092
|
## Commits
|
|
828
1093
|
${(result.commit_hashes || []).map((h) => `- \`${h}\``).join("\n") || "No commits recorded"}
|
|
829
|
-
|
|
1094
|
+
${autoMergeSection}
|
|
830
1095
|
## Next Step
|
|
831
1096
|
${result.next_step}
|
|
832
1097
|
|
|
833
|
-
>
|
|
1098
|
+
> ${isAutoMerge
|
|
1099
|
+
? "**Auto-Merge:** Execute the git commands above to merge and clean up the branch."
|
|
1100
|
+
: "**Ready for Review:** Human should review diff and move story to Done lane."}
|
|
1101
|
+
`;
|
|
1102
|
+
return {
|
|
1103
|
+
content: [{ type: "text", text: formattedResult.trim() }],
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
case "estimate_cost": {
|
|
1107
|
+
const args = request.params.arguments;
|
|
1108
|
+
const { storyId, provider, constraints } = args;
|
|
1109
|
+
if (!storyId)
|
|
1110
|
+
throw new Error("storyId is required");
|
|
1111
|
+
if (!provider)
|
|
1112
|
+
throw new Error("provider is required");
|
|
1113
|
+
if (!["gcp", "aws", "azure", "self-hosted"].includes(provider)) {
|
|
1114
|
+
throw new Error("provider must be one of: gcp, aws, azure, self-hosted");
|
|
1115
|
+
}
|
|
1116
|
+
// Fetch the story to get title and description
|
|
1117
|
+
const storyRes = await client.get(`/stories/${storyId}`);
|
|
1118
|
+
const storyData = storyRes.data;
|
|
1119
|
+
if (!storyData.description) {
|
|
1120
|
+
throw new Error("Story needs a description before cost estimation");
|
|
1121
|
+
}
|
|
1122
|
+
// Call the AI estimate-cost endpoint
|
|
1123
|
+
const estimateRes = await client.post("/ai/estimate-cost", {
|
|
1124
|
+
storyTitle: storyData.title,
|
|
1125
|
+
description: storyData.description,
|
|
1126
|
+
provider,
|
|
1127
|
+
...(constraints ? { constraints } : {}),
|
|
1128
|
+
});
|
|
1129
|
+
const estimate = estimateRes.data;
|
|
1130
|
+
// Save the estimate to the story
|
|
1131
|
+
await client.patch(`/stories/${storyId}`, {
|
|
1132
|
+
costEstimate: estimate,
|
|
1133
|
+
});
|
|
1134
|
+
// Format result as markdown
|
|
1135
|
+
const componentTable = estimate.components
|
|
1136
|
+
.map((c) => provider === "self-hosted"
|
|
1137
|
+
? `| ${c.name} | ${c.category} | ${c.resourceUnits || "-"} |`
|
|
1138
|
+
: `| ${c.name} | ${c.category} | $${c.monthlyCost?.toFixed(2)} | ${c.resourceUnits || "-"} |`)
|
|
1139
|
+
.join("\n");
|
|
1140
|
+
const tableHeader = provider === "self-hosted"
|
|
1141
|
+
? "| Component | Category | Resources |\n|-----------|----------|-----------|\n"
|
|
1142
|
+
: "| Component | Category | Monthly Cost | Resources |\n|-----------|----------|-------------|------------|\n";
|
|
1143
|
+
const formattedResult = `
|
|
1144
|
+
# Cost Estimate: ${storyData.title}
|
|
1145
|
+
|
|
1146
|
+
**Provider:** ${provider.toUpperCase()}
|
|
1147
|
+
**Confidence:** ${estimate.confidence}
|
|
1148
|
+
**Pricing Source:** ${estimate.pricingSource === "live" ? "Live API data" : "AI-estimated"}
|
|
1149
|
+
${provider !== "self-hosted" ? `**Total Monthly Cost:** $${estimate.totalMonthlyCost?.toLocaleString()}/mo` : "**Mode:** Self-hosted (resource units only)"}
|
|
1150
|
+
|
|
1151
|
+
## Components
|
|
1152
|
+
${tableHeader}${componentTable}
|
|
1153
|
+
|
|
1154
|
+
## Assumptions
|
|
1155
|
+
${(estimate.assumptions || []).map((a) => `- ${a}`).join("\n")}
|
|
1156
|
+
|
|
1157
|
+
> Estimate saved to story ${storyId}.
|
|
1158
|
+
`;
|
|
1159
|
+
return {
|
|
1160
|
+
content: [{ type: "text", text: formattedResult.trim() }],
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
case "get_epic_cost_rollup": {
|
|
1164
|
+
const args = request.params.arguments;
|
|
1165
|
+
const { epicId } = args;
|
|
1166
|
+
if (!epicId)
|
|
1167
|
+
throw new Error("epicId is required");
|
|
1168
|
+
// Fetch all stories and filter by epicId
|
|
1169
|
+
const allStories = await fetchStories();
|
|
1170
|
+
const epicStories = allStories.filter((s) => s.epicId === epicId);
|
|
1171
|
+
// Fetch epic info
|
|
1172
|
+
const epicsRes = await client.get("/epics");
|
|
1173
|
+
const epicsData = Array.isArray(epicsRes.data) ? epicsRes.data : [];
|
|
1174
|
+
const epic = epicsData.find((e) => e.id === epicId);
|
|
1175
|
+
const epicTitle = epic?.title || epicId;
|
|
1176
|
+
const estimated = epicStories.filter((s) => s.costEstimate);
|
|
1177
|
+
const unestimated = epicStories.filter((s) => !s.costEstimate);
|
|
1178
|
+
if (estimated.length === 0) {
|
|
1179
|
+
return {
|
|
1180
|
+
content: [{
|
|
1181
|
+
type: "text",
|
|
1182
|
+
text: `# Cost Rollup: ${epicTitle}\n\nNo stories have cost estimates yet. Use \`estimate_cost\` to estimate individual stories first.\n\n**Stories without estimates:** ${unestimated.length}`,
|
|
1183
|
+
}],
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
let totalMonthlyCost = 0;
|
|
1187
|
+
const byCategory = {};
|
|
1188
|
+
const providerCounts = {};
|
|
1189
|
+
estimated.forEach((s) => {
|
|
1190
|
+
const est = s.costEstimate;
|
|
1191
|
+
totalMonthlyCost += est.totalMonthlyCost || 0;
|
|
1192
|
+
providerCounts[est.provider] = (providerCounts[est.provider] || 0) + 1;
|
|
1193
|
+
(est.components || []).forEach((comp) => {
|
|
1194
|
+
byCategory[comp.category] = (byCategory[comp.category] || 0) + (comp.monthlyCost || 0);
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
const providers = Object.keys(providerCounts);
|
|
1198
|
+
const providerSummary = providers.length === 1
|
|
1199
|
+
? providers[0].toUpperCase()
|
|
1200
|
+
: providers.map((p) => `${p.toUpperCase()} (${providerCounts[p]})`).join(", ");
|
|
1201
|
+
const categoryBreakdown = Object.entries(byCategory)
|
|
1202
|
+
.sort(([, a], [, b]) => b - a)
|
|
1203
|
+
.map(([cat, cost]) => `| ${cat} | $${cost.toFixed(2)} |`)
|
|
1204
|
+
.join("\n");
|
|
1205
|
+
const formattedResult = `
|
|
1206
|
+
# Cost Rollup: ${epicTitle}
|
|
1207
|
+
|
|
1208
|
+
**Total Monthly Cost:** $${totalMonthlyCost.toLocaleString(undefined, { minimumFractionDigits: 2 })}/mo
|
|
1209
|
+
**Coverage:** ${estimated.length} of ${epicStories.length} stories estimated
|
|
1210
|
+
**Provider(s):** ${providerSummary}
|
|
1211
|
+
|
|
1212
|
+
## Cost by Category
|
|
1213
|
+
| Category | Monthly Cost |
|
|
1214
|
+
|----------|-------------|
|
|
1215
|
+
${categoryBreakdown}
|
|
1216
|
+
|
|
1217
|
+
## Stories Without Estimates
|
|
1218
|
+
${unestimated.length === 0
|
|
1219
|
+
? "All stories have been estimated!"
|
|
1220
|
+
: unestimated.map((s) => `- ${s.title} (${s.id})`).join("\n")}
|
|
834
1221
|
`;
|
|
835
1222
|
return {
|
|
836
1223
|
content: [{ type: "text", text: formattedResult.trim() }],
|