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.
Files changed (2) hide show
  1. package/index.js +675 -43
  2. 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 for the current user.
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
- Call this when the developer says "prepare my standup", "what did I do yesterday", or opens Cursor in the morning.`,
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
- // Fetch all my tasks
372
- const tasksRes = await api.get('/api/users/me/tasks')
373
- if (!tasksRes?.success) return errorText('Could not fetch tasks')
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
- const tasks = tasksRes.data.tasks
376
- if (!tasks.length) return text({ standup: 'No assigned tasks found.', tasks: [] })
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 each task (parallel)
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
- tasks.map(async (t) => {
435
+ activeTasks.map(async (t) => {
381
436
  try {
382
- const actRes = await api.get(`/api/tasks/${t._id}/activity`)
383
- return { ...t, activity: actRes?.data?.activity || [] }
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, activity: [] }
444
+ return { ...t, recentActivityLines: [], allActivity: [] }
386
445
  }
387
446
  })
388
447
  )
389
448
 
390
- // Summarise for standup
391
- const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
392
- const standup = withActivity.map((t) => {
393
- const recentActivity = t.activity.filter(
394
- (a) => new Date(a.createdAt) > yesterday
395
- )
396
- return {
397
- taskId: t._id,
398
- key: t.key,
399
- title: t.title,
400
- column: t.column,
401
- priority: t.priority,
402
- project: t.project?.name,
403
- parkNote: t.parkNote,
404
- recentActivity: recentActivity.map((a) => a.message || a.type),
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
- return text({ standup })
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
- const stateHint = localGitState?.state === 'modified'
966
- ? ` Your working tree has ${localGitState.modifiedFiles.length} modified file(s) — stash or commit them first, then call create_branch.`
967
- : ` Your working tree is ${localGitState?.state || 'unknown'} — ready to call create_branch.`
968
- coachingPrompt = `No branch yet.${stateHint}`
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
- const branchHint = onBranch === true
972
- ? ` (you are already on it)`
973
- : onBranch === false
974
- ? ` (you are on "${localGitState?.currentBranch}" — run: git checkout ${task.github.headBranch})`
975
- : ''
976
- coachingPrompt = `Branch: ${task.github.headBranch}${branchHint}. When commits are pushed, use raise_pr to open a PR.`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.5.5",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server for InternalTool — connect AI assistants (Claude Code, Cursor) to your project and task management platform",
5
5
  "type": "module",
6
6
  "main": "index.js",