internaltool-mcp 1.5.5 → 1.6.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/index.js +675 -43
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -356,56 +356,433 @@ Set confirmed=false first to preview, then confirmed=true to actually save.`,
|
|
|
356
356
|
)
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ── Standup activity formatter ────────────────────────────────────────────────
|
|
360
|
+
// Converts raw activity {action, meta} into a human-readable sentence.
|
|
361
|
+
function formatActivityLine(a) {
|
|
362
|
+
const m = a.meta || {}
|
|
363
|
+
switch (a.action) {
|
|
364
|
+
case 'task.created': return 'Task created'
|
|
365
|
+
case 'task.updated': return m.fields?.length ? `Updated: ${m.fields.join(', ')}` : 'Task updated'
|
|
366
|
+
case 'task.moved': return `Moved from ${m.from} → ${m.to}`
|
|
367
|
+
case 'task.branch_linked': return `Branch linked: ${m.headBranch}`
|
|
368
|
+
case 'comment.created': return 'Comment posted'
|
|
369
|
+
case 'approval.submitted': return `Sent for approval → ${m.reviewerName || 'reviewer'}`
|
|
370
|
+
case 'approval.approved': return `Plan approved${m.note ? ': ' + m.note : ''}`
|
|
371
|
+
case 'approval.rejected': return `Plan rejected${m.note ? ': ' + m.note : ''}`
|
|
372
|
+
case 'task.merged_from_github': return `PR #${m.prNumber} merged → done`
|
|
373
|
+
case 'task.pr_opened_to_review': return `PR #${m.prNumber} opened — moved to in_review`
|
|
374
|
+
case 'task.pr_linked_in_review': return `PR #${m.prNumber} linked (already in review)`
|
|
375
|
+
case 'task.pr_changes_requested': return `PR #${m.prNumber} — changes requested by ${m.reviewerLogin || 'reviewer'}`
|
|
376
|
+
case 'task.pr_closed_without_merge': return `PR #${m.prNumber} closed without merging`
|
|
377
|
+
case 'issue.created': return `Issue logged: "${m.title || '?'}"`
|
|
378
|
+
case 'issue.updated': return `Issue updated`
|
|
379
|
+
default: return a.action
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
359
383
|
function registerProductivityTools(server) {
|
|
360
384
|
server.tool(
|
|
361
385
|
'generate_standup',
|
|
362
|
-
`Generate a daily standup
|
|
363
|
-
Fetches all assigned tasks and their recent activity, then formats:
|
|
364
|
-
- What was done yesterday (based on activity + park notes)
|
|
365
|
-
- What is planned today (in_progress / todo tasks)
|
|
366
|
-
- Any blockers
|
|
386
|
+
`Generate a full daily standup — covers both the current user's tasks AND the entire project board.
|
|
367
387
|
|
|
368
|
-
|
|
388
|
+
Returns:
|
|
389
|
+
- yesterday: readable activity lines for the last 24 h per task
|
|
390
|
+
- today: all active tasks grouped by assignee, sorted by priority (critical first)
|
|
391
|
+
- blockers: park notes + tasks without branches/PRs that are overdue
|
|
392
|
+
- risks: critical/high tasks with no branch, tasks with PR changes requested
|
|
393
|
+
- copy-paste: a ready-to-post standup snippet
|
|
394
|
+
|
|
395
|
+
Call this when the developer says "generate standup", "what did I do yesterday", or "morning standup".`,
|
|
369
396
|
{},
|
|
370
397
|
async () => {
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
398
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
399
|
+
const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
400
|
+
|
|
401
|
+
// 1. Who am I?
|
|
402
|
+
const meRes = await api.get('/api/auth/me')
|
|
403
|
+
const me = meRes?.data?.user || {}
|
|
404
|
+
|
|
405
|
+
// 2. My tasks (all columns)
|
|
406
|
+
const myTasksRes = await api.get('/api/users/me/tasks')
|
|
407
|
+
const myTasks = myTasksRes?.data?.tasks || []
|
|
408
|
+
|
|
409
|
+
// 3. All my projects (to get project-wide board)
|
|
410
|
+
const projectsRes = await api.get('/api/projects')
|
|
411
|
+
const projects = projectsRes?.data?.projects || []
|
|
412
|
+
|
|
413
|
+
// 4. For each project, fetch full board to get all tasks (not just mine)
|
|
414
|
+
const projectBoards = await Promise.all(
|
|
415
|
+
projects.map(async (p) => {
|
|
416
|
+
try {
|
|
417
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
418
|
+
return r?.data?.project || null
|
|
419
|
+
} catch { return null }
|
|
420
|
+
})
|
|
421
|
+
)
|
|
374
422
|
|
|
375
|
-
|
|
376
|
-
|
|
423
|
+
// 5. Collect all unique tasks across all projects
|
|
424
|
+
const allTasksMap = new Map()
|
|
425
|
+
for (const board of projectBoards.filter(Boolean)) {
|
|
426
|
+
for (const t of (board.tasks || [])) {
|
|
427
|
+
allTasksMap.set(t._id, { ...t, projectName: board.name, projectId: board._id })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const allTasks = [...allTasksMap.values()]
|
|
377
431
|
|
|
378
|
-
// Fetch activity for
|
|
432
|
+
// 6. Fetch recent activity for active tasks (parallel, capped at 20 tasks to avoid rate limits)
|
|
433
|
+
const activeTasks = allTasks.filter(t => ['in_progress', 'in_review', 'todo'].includes(t.column))
|
|
379
434
|
const withActivity = await Promise.all(
|
|
380
|
-
|
|
435
|
+
activeTasks.map(async (t) => {
|
|
381
436
|
try {
|
|
382
|
-
const
|
|
383
|
-
|
|
437
|
+
const r = await api.get(`/api/tasks/${t._id}/activity`)
|
|
438
|
+
const activity = r?.data?.activity || []
|
|
439
|
+
const recentLines = activity
|
|
440
|
+
.filter(a => new Date(a.createdAt) > yesterday)
|
|
441
|
+
.map(a => formatActivityLine(a))
|
|
442
|
+
return { ...t, recentActivityLines: recentLines, allActivity: activity }
|
|
384
443
|
} catch {
|
|
385
|
-
return { ...t,
|
|
444
|
+
return { ...t, recentActivityLines: [], allActivity: [] }
|
|
386
445
|
}
|
|
387
446
|
})
|
|
388
447
|
)
|
|
389
448
|
|
|
390
|
-
//
|
|
391
|
-
const
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
449
|
+
// 7. Group by assignee
|
|
450
|
+
const byAssignee = {}
|
|
451
|
+
for (const t of withActivity) {
|
|
452
|
+
const assigneeList = (t.assignees || [])
|
|
453
|
+
if (!assigneeList.length) {
|
|
454
|
+
const key = 'Unassigned'
|
|
455
|
+
if (!byAssignee[key]) byAssignee[key] = []
|
|
456
|
+
byAssignee[key].push(t)
|
|
457
|
+
}
|
|
458
|
+
for (const a of assigneeList) {
|
|
459
|
+
const name = a.name || a.email || String(a)
|
|
460
|
+
if (!byAssignee[name]) byAssignee[name] = []
|
|
461
|
+
byAssignee[name].push(t)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Sort each person's tasks by priority
|
|
465
|
+
for (const name of Object.keys(byAssignee)) {
|
|
466
|
+
byAssignee[name].sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 8. Build "yesterday" section — tasks with recent activity
|
|
470
|
+
const yesterdayItems = withActivity
|
|
471
|
+
.filter(t => t.recentActivityLines.length > 0)
|
|
472
|
+
.map(t => ({
|
|
473
|
+
key: t.key,
|
|
474
|
+
title: t.title,
|
|
475
|
+
project: t.projectName,
|
|
476
|
+
assignee: (t.assignees || []).map(a => a.name || a.email).join(', ') || 'Unassigned',
|
|
477
|
+
activity: t.recentActivityLines,
|
|
478
|
+
}))
|
|
479
|
+
|
|
480
|
+
// 9. Risks + blockers
|
|
481
|
+
const risks = []
|
|
482
|
+
for (const t of withActivity) {
|
|
483
|
+
if (t.priority === 'critical' && t.column === 'todo') {
|
|
484
|
+
risks.push(`⚠️ ${t.key} (${t.projectName}) — CRITICAL but still in todo — needs kickoff now`)
|
|
405
485
|
}
|
|
486
|
+
if (t.priority === 'critical' && t.column === 'in_progress' && !t.github?.headBranch) {
|
|
487
|
+
risks.push(`⚠️ ${t.key} — CRITICAL, in_progress but no branch linked`)
|
|
488
|
+
}
|
|
489
|
+
if (t.github?.changesRequestedAt) {
|
|
490
|
+
risks.push(`🔁 ${t.key} — PR has changes requested — developer needs to fix and re-push`)
|
|
491
|
+
}
|
|
492
|
+
if (t.column === 'in_review' && !t.github?.prUrl) {
|
|
493
|
+
risks.push(`❓ ${t.key} — in_review column but no PR URL recorded`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const blockers = withActivity
|
|
498
|
+
.filter(t => t.parkNote?.blockers && t.parkNote.blockers.trim())
|
|
499
|
+
.map(t => `${t.key}: ${t.parkNote.blockers}`)
|
|
500
|
+
|
|
501
|
+
// 10. My personal section (my tasks with readable status)
|
|
502
|
+
const mySection = myTasks
|
|
503
|
+
.filter(t => ['in_progress', 'in_review', 'todo'].includes(t.column))
|
|
504
|
+
.map(t => {
|
|
505
|
+
const status = t.github?.headBranch
|
|
506
|
+
? (t.github.prUrl ? `PR open → ${t.github.prUrl}` : `branch: ${t.github.headBranch}, no PR yet`)
|
|
507
|
+
: 'no branch yet'
|
|
508
|
+
return `${t.key} — ${t.title} [${t.column}, ${t.priority}] — ${status}`
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// 11. Copy-paste snippet
|
|
512
|
+
const todayFocus = Object.entries(byAssignee)
|
|
513
|
+
.map(([person, tasks]) => ` ${person}: ${tasks.map(t => `${t.key} (${t.priority})`).join(', ')}`)
|
|
514
|
+
.join('\n')
|
|
515
|
+
const copyPaste = [
|
|
516
|
+
`**Yesterday:** ${yesterdayItems.length
|
|
517
|
+
? yesterdayItems.map(i => `${i.key} — ${i.activity.slice(0,2).join('; ')}`).join('. ')
|
|
518
|
+
: 'No recorded activity in last 24 h.'}`,
|
|
519
|
+
`**Today:**\n${todayFocus || ' No active tasks.'}`,
|
|
520
|
+
blockers.length ? `**Blockers:** ${blockers.join(' | ')}` : '**Blockers:** None.',
|
|
521
|
+
risks.length ? `**Risks:** ${risks.join(' | ')}` : null,
|
|
522
|
+
].filter(Boolean).join('\n')
|
|
523
|
+
|
|
524
|
+
return text({
|
|
525
|
+
me: { name: me.name, email: me.email, role: me.role },
|
|
526
|
+
yesterday: yesterdayItems.length ? yesterdayItems : '(no activity logged in last 24 h)',
|
|
527
|
+
today: {
|
|
528
|
+
byAssignee: Object.fromEntries(
|
|
529
|
+
Object.entries(byAssignee).map(([person, tasks]) => [
|
|
530
|
+
person,
|
|
531
|
+
tasks.map(t => ({
|
|
532
|
+
key: t.key,
|
|
533
|
+
title: t.title,
|
|
534
|
+
column: t.column,
|
|
535
|
+
priority: t.priority,
|
|
536
|
+
project: t.projectName,
|
|
537
|
+
branch: t.github?.headBranch || null,
|
|
538
|
+
prUrl: t.github?.prUrl || null,
|
|
539
|
+
parkNote: t.parkNote?.summary || null,
|
|
540
|
+
needsFix: !!t.github?.changesRequestedAt,
|
|
541
|
+
}))
|
|
542
|
+
])
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
myTasks: mySection,
|
|
546
|
+
blockers: blockers.length ? blockers : ['None recorded'],
|
|
547
|
+
risks: risks.length ? risks : ['None'],
|
|
548
|
+
copyPaste,
|
|
406
549
|
})
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
// ── what_should_i_work_on ─────────────────────────────────────────────────────
|
|
554
|
+
server.tool(
|
|
555
|
+
'what_should_i_work_on',
|
|
556
|
+
`Recommend the single most important task to work on right now.
|
|
557
|
+
|
|
558
|
+
Scores all assigned tasks by: priority, urgency (PR needs fix), column state, branch readiness.
|
|
559
|
+
Returns the #1 pick with a clear reason and the exact next MCP call to make.
|
|
560
|
+
|
|
561
|
+
Use this when the developer says "what should I do next", "I'm free", or "what's my priority".`,
|
|
562
|
+
{},
|
|
563
|
+
async () => {
|
|
564
|
+
const res = await api.get('/api/users/me/tasks')
|
|
565
|
+
if (!res?.success) return errorText('Could not fetch tasks')
|
|
566
|
+
const tasks = (res.data.tasks || []).filter(t => t.column !== 'done')
|
|
567
|
+
|
|
568
|
+
if (!tasks.length) return text({ recommendation: 'No open tasks assigned to you. Ask your project lead for work.' })
|
|
569
|
+
|
|
570
|
+
function scoreTask(t) {
|
|
571
|
+
const PRIORITY = { critical: 100, high: 60, medium: 30, low: 10 }
|
|
572
|
+
let score = PRIORITY[t.priority] ?? 0
|
|
573
|
+
if (t.github?.changesRequestedAt) score += 80 // PR needs fix — most urgent
|
|
574
|
+
if (t.column === 'in_progress') score += 25 // already in flight, keep momentum
|
|
575
|
+
if (t.column === 'in_review') score += 10 // waiting on reviewer, low action needed
|
|
576
|
+
if (t.column === 'todo') score += 15 // ready to start
|
|
577
|
+
if (t.column === 'backlog') score -= 10 // not yet planned
|
|
578
|
+
if (t.parkNote?.parkedAt) score -= 20 // intentionally paused
|
|
579
|
+
if (t.github?.headBranch) score += 5 // branch already set up
|
|
580
|
+
return score
|
|
581
|
+
}
|
|
407
582
|
|
|
408
|
-
|
|
583
|
+
const scored = tasks
|
|
584
|
+
.map(t => ({ ...t, _score: scoreTask(t) }))
|
|
585
|
+
.sort((a, b) => b._score - a._score)
|
|
586
|
+
|
|
587
|
+
const top = scored[0]
|
|
588
|
+
|
|
589
|
+
// Build the reason and next step
|
|
590
|
+
let reason, nextStep
|
|
591
|
+
if (top.github?.changesRequestedAt) {
|
|
592
|
+
reason = `PR has changes requested — this blocks the merge and the reviewer is waiting.`
|
|
593
|
+
nextStep = `Call fix_pr_feedback with taskAId="${top._id}" to get the full fix plan.`
|
|
594
|
+
} else if (top.column === 'in_progress' && top.github?.headBranch) {
|
|
595
|
+
reason = `Already in progress on branch ${top.github.headBranch} — keep the momentum going.`
|
|
596
|
+
nextStep = `Continue coding on ${top.github.headBranch}. When commits are pushed, call raise_pr.`
|
|
597
|
+
} else if (top.column === 'in_progress' && !top.github?.headBranch) {
|
|
598
|
+
reason = `In progress but no branch yet — needs a branch to start committing.`
|
|
599
|
+
nextStep = `Call create_branch with taskId="${top._id}" and projectId="${top.project?._id || top.project}".`
|
|
600
|
+
} else if (top.column === 'todo') {
|
|
601
|
+
reason = `Highest priority unstarted task — ready to kick off.`
|
|
602
|
+
nextStep = `Call kickoff_task with taskId="${top._id}" to read the plan and move to in_progress.`
|
|
603
|
+
} else {
|
|
604
|
+
reason = `Highest priority available task.`
|
|
605
|
+
nextStep = `Call get_task_context with taskId="${top._id}" for full details.`
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Show the queue (top 3)
|
|
609
|
+
const queue = scored.slice(0, 3).map((t, i) => ({
|
|
610
|
+
rank: i + 1,
|
|
611
|
+
key: t.key,
|
|
612
|
+
title: t.title,
|
|
613
|
+
priority: t.priority,
|
|
614
|
+
column: t.column,
|
|
615
|
+
score: t._score,
|
|
616
|
+
branch: t.github?.headBranch || null,
|
|
617
|
+
needsFix: !!t.github?.changesRequestedAt,
|
|
618
|
+
}))
|
|
619
|
+
|
|
620
|
+
return text({
|
|
621
|
+
recommendation: {
|
|
622
|
+
taskId: top._id,
|
|
623
|
+
key: top.key,
|
|
624
|
+
title: top.title,
|
|
625
|
+
priority: top.priority,
|
|
626
|
+
column: top.column,
|
|
627
|
+
project: top.project?.name || top.project,
|
|
628
|
+
reason,
|
|
629
|
+
nextStep,
|
|
630
|
+
},
|
|
631
|
+
queue,
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
// ── task_health_check ────────────────────────────────────────────────────────
|
|
637
|
+
server.tool(
|
|
638
|
+
'task_health_check',
|
|
639
|
+
`Scan the entire project board for health issues and surface them with severity levels.
|
|
640
|
+
|
|
641
|
+
Detects:
|
|
642
|
+
- Critical tasks sitting in todo without a branch (needs kickoff now)
|
|
643
|
+
- Tasks in review for 3+ days (reviewer may be stuck)
|
|
644
|
+
- Tasks in progress for 2+ days with no updates (possibly stale or blocked)
|
|
645
|
+
- In-progress tasks with no branch linked
|
|
646
|
+
- Active tasks with no README/implementation plan
|
|
647
|
+
- PRs with changes requested that haven't been fixed
|
|
648
|
+
|
|
649
|
+
Use this when the developer or team lead asks "how's the board?", "any blockers?", or for a weekly health review.`,
|
|
650
|
+
{},
|
|
651
|
+
async () => {
|
|
652
|
+
const projectsRes = await api.get('/api/projects')
|
|
653
|
+
const projects = projectsRes?.data?.projects || []
|
|
654
|
+
|
|
655
|
+
const boards = await Promise.all(
|
|
656
|
+
projects.map(async p => {
|
|
657
|
+
try {
|
|
658
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
659
|
+
return r?.data?.project || null
|
|
660
|
+
} catch { return null }
|
|
661
|
+
})
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
const now = Date.now()
|
|
665
|
+
const MS_PER_DAY = 1000 * 60 * 60 * 24
|
|
666
|
+
const flags = []
|
|
667
|
+
|
|
668
|
+
for (const board of boards.filter(Boolean)) {
|
|
669
|
+
for (const t of (board.tasks || [])) {
|
|
670
|
+
if (t.column === 'done') continue
|
|
671
|
+
const ageDays = (now - new Date(t.updatedAt).getTime()) / MS_PER_DAY
|
|
672
|
+
const assignees = (t.assignees || []).map(a => a.name || a.email).join(', ') || 'Unassigned'
|
|
673
|
+
|
|
674
|
+
// PR changes requested — top priority
|
|
675
|
+
if (t.github?.changesRequestedAt) {
|
|
676
|
+
const daysSince = (now - new Date(t.github.changesRequestedAt).getTime()) / MS_PER_DAY
|
|
677
|
+
flags.push({
|
|
678
|
+
severity: 'critical',
|
|
679
|
+
task: t.key,
|
|
680
|
+
title: t.title,
|
|
681
|
+
project: board.name,
|
|
682
|
+
assignee: assignees,
|
|
683
|
+
issue: `PR has had changes requested for ${Math.floor(daysSince)} day(s) — developer needs to fix and re-push`,
|
|
684
|
+
suggestion: `Use fix_pr_feedback with taskAId="${t._id}"`,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Critical task stuck in todo
|
|
689
|
+
if (t.priority === 'critical' && t.column === 'todo') {
|
|
690
|
+
flags.push({
|
|
691
|
+
severity: 'critical',
|
|
692
|
+
task: t.key,
|
|
693
|
+
title: t.title,
|
|
694
|
+
project: board.name,
|
|
695
|
+
assignee: assignees,
|
|
696
|
+
issue: `CRITICAL priority — stuck in todo for ${Math.floor(ageDays)} day(s)`,
|
|
697
|
+
suggestion: `Use kickoff_task with taskId="${t._id}" to start immediately`,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// High priority in todo for more than 1 day
|
|
702
|
+
if (t.priority === 'high' && t.column === 'todo' && ageDays > 1) {
|
|
703
|
+
flags.push({
|
|
704
|
+
severity: 'warning',
|
|
705
|
+
task: t.key,
|
|
706
|
+
title: t.title,
|
|
707
|
+
project: board.name,
|
|
708
|
+
assignee: assignees,
|
|
709
|
+
issue: `HIGH priority — in todo for ${Math.floor(ageDays)} day(s) without being started`,
|
|
710
|
+
suggestion: `Use kickoff_task to start`,
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// In review for 3+ days
|
|
715
|
+
if (t.column === 'in_review' && ageDays > 3) {
|
|
716
|
+
flags.push({
|
|
717
|
+
severity: 'warning',
|
|
718
|
+
task: t.key,
|
|
719
|
+
title: t.title,
|
|
720
|
+
project: board.name,
|
|
721
|
+
assignee: assignees,
|
|
722
|
+
issue: `In review for ${Math.floor(ageDays)} days — reviewer may be stuck or PR is forgotten`,
|
|
723
|
+
suggestion: `Check PR status on GitHub or ping the reviewer`,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// In progress for 2+ days with no updates
|
|
728
|
+
if (t.column === 'in_progress' && ageDays > 2) {
|
|
729
|
+
flags.push({
|
|
730
|
+
severity: 'warning',
|
|
731
|
+
task: t.key,
|
|
732
|
+
title: t.title,
|
|
733
|
+
project: board.name,
|
|
734
|
+
assignee: assignees,
|
|
735
|
+
issue: `In progress for ${Math.floor(ageDays)} day(s) with no board updates — possibly stale or silently blocked`,
|
|
736
|
+
suggestion: `Ask ${assignees} for a status update or check git log on ${t.github?.headBranch || 'their branch'}`,
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// In progress but no branch linked
|
|
741
|
+
if (t.column === 'in_progress' && !t.github?.headBranch) {
|
|
742
|
+
flags.push({
|
|
743
|
+
severity: 'warning',
|
|
744
|
+
task: t.key,
|
|
745
|
+
title: t.title,
|
|
746
|
+
project: board.name,
|
|
747
|
+
assignee: assignees,
|
|
748
|
+
issue: 'In progress but no branch linked — commits may be going to the wrong branch',
|
|
749
|
+
suggestion: `Use create_branch with taskId="${t._id}" to create and link a branch`,
|
|
750
|
+
})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Active task with no README
|
|
754
|
+
if (['in_progress', 'in_review'].includes(t.column) && !t.readmeMarkdown?.trim()) {
|
|
755
|
+
flags.push({
|
|
756
|
+
severity: 'info',
|
|
757
|
+
task: t.key,
|
|
758
|
+
title: t.title,
|
|
759
|
+
project: board.name,
|
|
760
|
+
assignee: assignees,
|
|
761
|
+
issue: 'No implementation plan (README) — harder to review and hard to resume if parked',
|
|
762
|
+
suggestion: `Use update_task to add a readmeMarkdown implementation plan`,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Sort: critical → warning → info
|
|
769
|
+
const SEV = { critical: 0, warning: 1, info: 2 }
|
|
770
|
+
flags.sort((a, b) => (SEV[a.severity] ?? 9) - (SEV[b.severity] ?? 9))
|
|
771
|
+
|
|
772
|
+
const counts = {
|
|
773
|
+
critical: flags.filter(f => f.severity === 'critical').length,
|
|
774
|
+
warning: flags.filter(f => f.severity === 'warning').length,
|
|
775
|
+
info: flags.filter(f => f.severity === 'info').length,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return text({
|
|
779
|
+
summary: flags.length === 0
|
|
780
|
+
? '✅ Board is healthy — no issues found.'
|
|
781
|
+
: `⚠️ ${flags.length} issue(s): ${counts.critical} critical, ${counts.warning} warnings, ${counts.info} info`,
|
|
782
|
+
counts,
|
|
783
|
+
flags,
|
|
784
|
+
healthy: flags.length === 0,
|
|
785
|
+
})
|
|
409
786
|
}
|
|
410
787
|
)
|
|
411
788
|
|
|
@@ -962,18 +1339,43 @@ Use this when returning to a task after a break, or when Claude needs the full p
|
|
|
962
1339
|
|
|
963
1340
|
let coachingPrompt = null
|
|
964
1341
|
if (task.column === 'in_progress' && !task.github?.headBranch) {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1342
|
+
let stateHint
|
|
1343
|
+
if (localGitState?.state === 'modified') {
|
|
1344
|
+
const files = localGitState.modifiedFiles.join(', ')
|
|
1345
|
+
stateHint = [
|
|
1346
|
+
`You have ${localGitState.modifiedFiles.length} modified file(s): ${files}`,
|
|
1347
|
+
`Before creating the task branch, handle these changes — pick one:`,
|
|
1348
|
+
` Option A — Stash (recommended, keeps changes safe):`,
|
|
1349
|
+
` git stash push -m "wip: before starting ${task.key}"`,
|
|
1350
|
+
` Option B — Commit as WIP:`,
|
|
1351
|
+
` git add . && git commit -m "chore: wip before ${task.key}"`,
|
|
1352
|
+
`Then call create_branch — it will detect the clean state automatically.`,
|
|
1353
|
+
].join('\n')
|
|
1354
|
+
} else {
|
|
1355
|
+
stateHint = `Your working tree is ${localGitState?.state || 'unknown'} — ready to call create_branch.`
|
|
1356
|
+
}
|
|
1357
|
+
coachingPrompt = `No branch yet.\n${stateHint}`
|
|
969
1358
|
} else if (task.column === 'in_progress' && task.github?.headBranch) {
|
|
970
1359
|
const onBranch = localGitState?.onTaskBranch
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1360
|
+
if (onBranch === false) {
|
|
1361
|
+
const lines = [
|
|
1362
|
+
`Branch exists: ${task.github.headBranch}`,
|
|
1363
|
+
`You are currently on "${localGitState?.currentBranch}".`,
|
|
1364
|
+
]
|
|
1365
|
+
if (localGitState?.state === 'modified') {
|
|
1366
|
+
lines.push(`You have ${localGitState.modifiedFiles.length} modified file(s): ${localGitState.modifiedFiles.join(', ')}`)
|
|
1367
|
+
lines.push(`Handle them before switching:`)
|
|
1368
|
+
lines.push(` git stash push -m "wip: switching to ${task.github.headBranch}"`)
|
|
1369
|
+
lines.push(` — or — git add . && git commit -m "chore: wip"`)
|
|
1370
|
+
lines.push(`Then switch: git fetch origin && git checkout ${task.github.headBranch}`)
|
|
1371
|
+
} else {
|
|
1372
|
+
lines.push(`Switch to the task branch: git fetch origin && git checkout ${task.github.headBranch}`)
|
|
1373
|
+
}
|
|
1374
|
+
lines.push(`When commits are pushed, use raise_pr.`)
|
|
1375
|
+
coachingPrompt = lines.join('\n')
|
|
1376
|
+
} else {
|
|
1377
|
+
coachingPrompt = `Branch: ${task.github.headBranch}${onBranch === true ? ' (you are already on it)' : ''}. When commits are pushed, use raise_pr to open a PR.`
|
|
1378
|
+
}
|
|
977
1379
|
} else if (task.column === 'in_review') {
|
|
978
1380
|
coachingPrompt = `PR #${task.github?.prNumber} is open. Waiting for reviewer feedback.`
|
|
979
1381
|
} else if (task.parkNote?.parkedAt) {
|
|
@@ -1117,12 +1519,17 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1117
1519
|
})
|
|
1118
1520
|
}
|
|
1119
1521
|
|
|
1120
|
-
// Step 3 — create branch on GitHub
|
|
1522
|
+
// Step 3 — create branch on GitHub, then link it to the task
|
|
1121
1523
|
const localState = gitState?.localState || 'unknown'
|
|
1122
1524
|
try {
|
|
1123
1525
|
const res = await api.post(`/api/projects/${projectId}/github/branches`, { branchName, fromRef })
|
|
1124
1526
|
if (!res?.success) return errorText(res?.message || 'Could not create branch')
|
|
1125
1527
|
|
|
1528
|
+
// Link the branch name back to the task so get_task_context / list_my_tasks reflect it immediately
|
|
1529
|
+
try {
|
|
1530
|
+
await api.patch(`/api/tasks/${taskId}/github/branch`, { headBranch: branchName })
|
|
1531
|
+
} catch { /* non-fatal — branch was still created on GitHub */ }
|
|
1532
|
+
|
|
1126
1533
|
const checkoutSteps = [
|
|
1127
1534
|
'git fetch origin',
|
|
1128
1535
|
`git checkout ${branchName}`,
|
|
@@ -1440,6 +1847,231 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
|
|
|
1440
1847
|
}
|
|
1441
1848
|
}
|
|
1442
1849
|
)
|
|
1850
|
+
|
|
1851
|
+
// ── commit_helper ─────────────────────────────────────────────────────────────
|
|
1852
|
+
server.tool(
|
|
1853
|
+
'commit_helper',
|
|
1854
|
+
`Analyse local git changes and suggest a ready-to-run conventional commit command.
|
|
1855
|
+
|
|
1856
|
+
Reads git diff --stat and git status locally — no manual commands needed.
|
|
1857
|
+
Generates a commit message following: <type>(<scope>): <description>
|
|
1858
|
+
type = feat | fix | refactor | chore | docs | test | style
|
|
1859
|
+
scope = task key (e.g. TASK-003) when taskId is provided
|
|
1860
|
+
|
|
1861
|
+
Set confirmed=false to preview the suggested message, confirmed=true to get the final copy-paste command.
|
|
1862
|
+
|
|
1863
|
+
Use this when the developer asks "help me commit", "what should my commit message be", or "how do I commit this".`,
|
|
1864
|
+
{
|
|
1865
|
+
taskId: z.string().optional().describe("Task's MongoDB ObjectId — used to set the commit scope to the task key"),
|
|
1866
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
|
|
1867
|
+
confirmed: z.boolean().optional().default(false).describe('Set true to get the final runnable command'),
|
|
1868
|
+
},
|
|
1869
|
+
async ({ taskId, repoPath, confirmed = false }) => {
|
|
1870
|
+
const cwd = repoPath || process.cwd()
|
|
1871
|
+
|
|
1872
|
+
// Read local git state
|
|
1873
|
+
let porcelain = '', diffStat = '', currentBranch = ''
|
|
1874
|
+
try {
|
|
1875
|
+
porcelain = runGit('status --porcelain=v1', cwd)
|
|
1876
|
+
diffStat = runGit('diff --stat HEAD', cwd)
|
|
1877
|
+
currentBranch = runGit('branch --show-current', cwd)
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
return errorText(`Could not read git state: ${e.message.split('\n')[0]}`)
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const { staged, unstaged, untracked, modified } = parseGitStatus(porcelain)
|
|
1883
|
+
|
|
1884
|
+
if (modified.length === 0 && untracked.length === 0) {
|
|
1885
|
+
return text({ message: 'Nothing to commit — working tree is clean.', currentBranch })
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Fetch task context for scope/type hints
|
|
1889
|
+
let task = null
|
|
1890
|
+
if (taskId) {
|
|
1891
|
+
try {
|
|
1892
|
+
const r = await api.get(`/api/tasks/${taskId}`)
|
|
1893
|
+
if (r?.success) task = r.data.task
|
|
1894
|
+
} catch { /* non-fatal */ }
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// Infer commit type from branch name + diff content
|
|
1898
|
+
const branchLower = currentBranch.toLowerCase()
|
|
1899
|
+
const diffLower = diffStat.toLowerCase()
|
|
1900
|
+
let commitType = 'chore'
|
|
1901
|
+
if (branchLower.startsWith('feature/') || branchLower.startsWith('feat/')) commitType = 'feat'
|
|
1902
|
+
else if (branchLower.startsWith('fix/') || branchLower.startsWith('hotfix/')) commitType = 'fix'
|
|
1903
|
+
else if (branchLower.startsWith('refactor/')) commitType = 'refactor'
|
|
1904
|
+
else if (branchLower.startsWith('docs/')) commitType = 'docs'
|
|
1905
|
+
else if (branchLower.startsWith('test/')) commitType = 'test'
|
|
1906
|
+
else if (diffLower.includes('test') || diffLower.includes('spec')) commitType = 'test'
|
|
1907
|
+
else if (diffLower.includes('readme') || diffLower.includes('.md')) commitType = 'docs'
|
|
1908
|
+
else if (task) {
|
|
1909
|
+
const titleLower = (task.title || '').toLowerCase()
|
|
1910
|
+
if (/\b(fix|bug|hotfix|patch)\b/.test(titleLower)) commitType = 'fix'
|
|
1911
|
+
else if (/\b(refactor|cleanup|clean up)\b/.test(titleLower)) commitType = 'refactor'
|
|
1912
|
+
else commitType = 'feat'
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// Build scope
|
|
1916
|
+
const scope = task?.key?.toLowerCase() || ''
|
|
1917
|
+
|
|
1918
|
+
// Build description from task title or changed files
|
|
1919
|
+
let description = ''
|
|
1920
|
+
if (task) {
|
|
1921
|
+
description = task.title
|
|
1922
|
+
.toLowerCase()
|
|
1923
|
+
.replace(/[^a-z0-9 ]+/g, '')
|
|
1924
|
+
.replace(/\s+/g, ' ')
|
|
1925
|
+
.trim()
|
|
1926
|
+
.slice(0, 60)
|
|
1927
|
+
} else {
|
|
1928
|
+
// Summarise from changed files
|
|
1929
|
+
const allFiles = [...modified, ...untracked]
|
|
1930
|
+
if (allFiles.length === 1) {
|
|
1931
|
+
description = `update ${allFiles[0].split('/').pop()}`
|
|
1932
|
+
} else {
|
|
1933
|
+
description = `update ${allFiles.length} file(s)`
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const commitMsg = scope
|
|
1938
|
+
? `${commitType}(${scope}): ${description}`
|
|
1939
|
+
: `${commitType}: ${description}`
|
|
1940
|
+
|
|
1941
|
+
// Staged vs not staged — build the right add command
|
|
1942
|
+
const hasUnstaged = unstaged.length > 0 || untracked.length > 0
|
|
1943
|
+
const hasStaged = staged.length > 0
|
|
1944
|
+
const addCmd = hasUnstaged ? 'git add .' : null
|
|
1945
|
+
const commitCmd = `git commit -m "${commitMsg}"`
|
|
1946
|
+
const pushCmd = `git push origin ${currentBranch}`
|
|
1947
|
+
|
|
1948
|
+
const changedFiles = [
|
|
1949
|
+
...staged.map(f => ` staged: ${f.file}`),
|
|
1950
|
+
...unstaged.map(f => ` unstaged: ${f.file}`),
|
|
1951
|
+
...untracked.map(f => ` untracked: ${f}`),
|
|
1952
|
+
]
|
|
1953
|
+
|
|
1954
|
+
if (!confirmed) {
|
|
1955
|
+
return text({
|
|
1956
|
+
preview: {
|
|
1957
|
+
suggestedMessage: commitMsg,
|
|
1958
|
+
type: commitType,
|
|
1959
|
+
scope,
|
|
1960
|
+
description,
|
|
1961
|
+
currentBranch,
|
|
1962
|
+
changedFiles,
|
|
1963
|
+
diffSummary: diffStat || '(no tracked changes yet)',
|
|
1964
|
+
},
|
|
1965
|
+
commands: [addCmd, commitCmd, pushCmd].filter(Boolean),
|
|
1966
|
+
requiresConfirmation: true,
|
|
1967
|
+
message: `Suggested commit message: "${commitMsg}". Call commit_helper again with confirmed=true to get the final runnable commands.`,
|
|
1968
|
+
tip: 'You can edit the description before running — the type and scope are auto-detected but the description can be more specific.',
|
|
1969
|
+
})
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
return text({
|
|
1973
|
+
commitMessage: commitMsg,
|
|
1974
|
+
commands: [addCmd, commitCmd, pushCmd].filter(Boolean),
|
|
1975
|
+
changedFiles,
|
|
1976
|
+
message: `Copy-paste these commands in order. Edit the commit message description if needed before running.`,
|
|
1977
|
+
nextStep: 'After pushing, call raise_pr to open the pull request.',
|
|
1978
|
+
})
|
|
1979
|
+
}
|
|
1980
|
+
)
|
|
1981
|
+
|
|
1982
|
+
// ── branch_cleanup ────────────────────────────────────────────────────────────
|
|
1983
|
+
server.tool(
|
|
1984
|
+
'branch_cleanup',
|
|
1985
|
+
`Find merged branches that are safe to delete — both on GitHub and locally.
|
|
1986
|
+
|
|
1987
|
+
Checks GitHub branches against tasks that have been merged (github.mergedAt set).
|
|
1988
|
+
Also runs "git branch --merged origin/main" locally to find stale local branches.
|
|
1989
|
+
|
|
1990
|
+
Set confirmed=false to preview the list, confirmed=true to get the exact delete commands.
|
|
1991
|
+
|
|
1992
|
+
Use this when the developer says "clean up branches", "delete merged branches", or after a sprint ends.`,
|
|
1993
|
+
{
|
|
1994
|
+
projectId: z.string().describe("Project's MongoDB ObjectId"),
|
|
1995
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo'),
|
|
1996
|
+
confirmed: z.boolean().optional().default(false).describe('Set true to get the delete commands'),
|
|
1997
|
+
},
|
|
1998
|
+
async ({ projectId, repoPath, confirmed = false }) => {
|
|
1999
|
+
if (scopedProjectId && projectId !== scopedProjectId) {
|
|
2000
|
+
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Fetch GitHub branches
|
|
2004
|
+
const branchesRes = await api.get(`/api/projects/${projectId}/github/branches`)
|
|
2005
|
+
if (!branchesRes?.success) return errorText('Could not fetch GitHub branches')
|
|
2006
|
+
const remoteBranches = (branchesRes.data.branches || []).map(b => b.name || b)
|
|
2007
|
+
|
|
2008
|
+
// Fetch all tasks to find which branches are merged
|
|
2009
|
+
const projectRes = await api.get(`/api/projects/${projectId}`)
|
|
2010
|
+
const tasks = projectRes?.data?.project?.tasks || []
|
|
2011
|
+
const defaultBranch = projectRes?.data?.project?.githubDefaultBranch || 'main'
|
|
2012
|
+
|
|
2013
|
+
// Branches linked to merged tasks
|
|
2014
|
+
const mergedTaskBranches = new Set(
|
|
2015
|
+
tasks
|
|
2016
|
+
.filter(t => t.github?.mergedAt && t.github?.headBranch)
|
|
2017
|
+
.map(t => t.github.headBranch)
|
|
2018
|
+
)
|
|
2019
|
+
|
|
2020
|
+
// Remote branches safe to delete (merged task branch, not the default branch)
|
|
2021
|
+
const remoteToDelete = remoteBranches.filter(b =>
|
|
2022
|
+
mergedTaskBranches.has(b) && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev'
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
// Local merged branches
|
|
2026
|
+
const cwd = repoPath || process.cwd()
|
|
2027
|
+
let localMerged = []
|
|
2028
|
+
try {
|
|
2029
|
+
const localRaw = runGit(`branch --merged origin/${defaultBranch}`, cwd)
|
|
2030
|
+
localMerged = localRaw
|
|
2031
|
+
.split('\n')
|
|
2032
|
+
.map(b => b.replace(/^\*?\s+/, '').trim())
|
|
2033
|
+
.filter(b => b && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev')
|
|
2034
|
+
} catch { /* git may not be available or no upstream */ }
|
|
2035
|
+
|
|
2036
|
+
if (!confirmed) {
|
|
2037
|
+
return text({
|
|
2038
|
+
preview: {
|
|
2039
|
+
remoteBranchesToDelete: remoteToDelete,
|
|
2040
|
+
localBranchesToDelete: localMerged,
|
|
2041
|
+
defaultBranch,
|
|
2042
|
+
remoteCount: remoteToDelete.length,
|
|
2043
|
+
localCount: localMerged.length,
|
|
2044
|
+
},
|
|
2045
|
+
requiresConfirmation: true,
|
|
2046
|
+
message: remoteToDelete.length === 0 && localMerged.length === 0
|
|
2047
|
+
? 'No merged branches found to clean up. Repository is already clean.'
|
|
2048
|
+
: `Found ${remoteToDelete.length} remote and ${localMerged.length} local branch(es) to delete. Call branch_cleanup again with confirmed=true to get the delete commands.`,
|
|
2049
|
+
safetyNote: 'Only branches linked to tasks with a merged PR are included. Default branch, main, master, and dev are always excluded.',
|
|
2050
|
+
})
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const remoteDeleteCmds = remoteToDelete.map(b => `git push origin --delete ${b}`)
|
|
2054
|
+
const localDeleteCmds = localMerged.map(b => `git branch -d ${b}`)
|
|
2055
|
+
const allCmds = [
|
|
2056
|
+
'# Fetch latest remote state first',
|
|
2057
|
+
`git fetch origin --prune`,
|
|
2058
|
+
'',
|
|
2059
|
+
remoteDeleteCmds.length ? '# Delete merged remote branches' : null,
|
|
2060
|
+
...remoteDeleteCmds,
|
|
2061
|
+
'',
|
|
2062
|
+
localDeleteCmds.length ? '# Delete merged local branches' : null,
|
|
2063
|
+
...localDeleteCmds,
|
|
2064
|
+
].filter(v => v !== null)
|
|
2065
|
+
|
|
2066
|
+
return text({
|
|
2067
|
+
commands: allCmds,
|
|
2068
|
+
remoteBranchesDeleted: remoteToDelete,
|
|
2069
|
+
localBranchesDeleted: localMerged,
|
|
2070
|
+
message: `Run the commands above to clean up ${remoteToDelete.length} remote and ${localMerged.length} local branch(es).`,
|
|
2071
|
+
tip: 'git fetch --prune also removes stale remote-tracking refs automatically.',
|
|
2072
|
+
})
|
|
2073
|
+
}
|
|
2074
|
+
)
|
|
1443
2075
|
}
|
|
1444
2076
|
|
|
1445
2077
|
function registerAdminTools(server) {
|
package/package.json
CHANGED