internaltool-mcp 1.5.7 → 1.6.1

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 +766 -32
  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 || []
374
408
 
375
- const tasks = tasksRes.data.tasks
376
- if (!tasks.length) return text({ standup: 'No assigned tasks found.', tasks: [] })
409
+ // 3. All my projects (to get project-wide board)
410
+ const projectsRes = await api.get('/api/projects')
411
+ const projects = projectsRes?.data?.projects || []
377
412
 
378
- // Fetch activity for each task (parallel)
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
+ )
422
+
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()]
431
+
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`)
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`)
405
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,
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
+ }
582
+
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,
406
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
+ }
407
739
 
408
- return text({ standup })
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
 
@@ -1470,6 +1847,363 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
1470
1847
  }
1471
1848
  }
1472
1849
  )
1850
+
1851
+ // ── commit_helper ─────────────────────────────────────────────────────────────
1852
+ server.tool(
1853
+ 'commit_helper',
1854
+ `Analyse local git changes and produce a ready-to-run conventional commit command.
1855
+
1856
+ Auto-detects local branch, changed files, and — when taskId is given — the task's linked branch.
1857
+ If the current branch does not match the task's branch, presents three options before doing anything:
1858
+ A) Switch to the task branch first, bring changes with you, then commit
1859
+ B) Stash changes now and commit on the task branch later
1860
+ C) Commit on the current branch (warns if that is main/master/dev)
1861
+
1862
+ Commit message format: <type>(<scope>): <description>
1863
+ type = feat | fix | refactor | chore | docs | test | style (auto-inferred)
1864
+ scope = task key (e.g. task-003) when taskId is provided
1865
+
1866
+ Also flags unsafe patterns before generating any command:
1867
+ - Untracked IDE/config dirs (.cursor/, .idea/, .vscode/, node_modules/)
1868
+ - Committing directly to main / master / dev without a task branch
1869
+
1870
+ TWO-STEP FLOW:
1871
+ confirmed=false → detect state, show branch mismatch options or commit preview
1872
+ confirmed=true + branchAction → execute the chosen path
1873
+
1874
+ branchAction values (only needed when current branch ≠ task branch):
1875
+ "switch_then_commit" — stash → checkout task branch → pop stash → commit + push
1876
+ "stash_for_later" — stash only; commit later when on the task branch
1877
+ "commit_here" — commit on current branch (use only when changes truly belong here)`,
1878
+ {
1879
+ taskId: z.string().optional().describe("Task's MongoDB ObjectId"),
1880
+ repoPath: z.string().optional().describe('Absolute path to the local git repo'),
1881
+ confirmed: z.boolean().optional().default(false),
1882
+ branchAction: z.enum(['switch_then_commit', 'stash_for_later', 'commit_here'])
1883
+ .optional()
1884
+ .describe('Required when confirmed=true and current branch ≠ task branch'),
1885
+ },
1886
+ async ({ taskId, repoPath, confirmed = false, branchAction }) => {
1887
+ const cwd = repoPath || process.cwd()
1888
+
1889
+ // ── Read local git state ──────────────────────────────────────────────────
1890
+ let porcelain = '', diffStat = '', currentBranch = ''
1891
+ try {
1892
+ porcelain = runGit('status --porcelain=v1', cwd)
1893
+ diffStat = runGit('diff --stat HEAD', cwd)
1894
+ currentBranch = runGit('branch --show-current', cwd)
1895
+ } catch (e) {
1896
+ return errorText(`Could not read git state: ${e.message.split('\n')[0]}`)
1897
+ }
1898
+
1899
+ const { staged, unstaged, untracked, modified } = parseGitStatus(porcelain)
1900
+
1901
+ if (modified.length === 0 && untracked.length === 0) {
1902
+ return text({ message: 'Nothing to commit — working tree is clean.', currentBranch })
1903
+ }
1904
+
1905
+ // ── Fetch task ────────────────────────────────────────────────────────────
1906
+ let task = null
1907
+ if (taskId) {
1908
+ try {
1909
+ const r = await api.get(`/api/tasks/${taskId}`)
1910
+ if (r?.success) task = r.data.task
1911
+ } catch { /* non-fatal */ }
1912
+ }
1913
+
1914
+ const taskBranch = task?.github?.headBranch || null
1915
+ const PROTECTED = ['main', 'master', 'dev', 'develop', 'staging', 'production']
1916
+ const onProtected = PROTECTED.includes(currentBranch)
1917
+ const branchMismatch = taskBranch && currentBranch !== taskBranch
1918
+
1919
+ // ── Detect unsafe untracked paths ─────────────────────────────────────────
1920
+ const NOCOMMIT_PATTERNS = ['.cursor', '.idea', '.vscode', 'node_modules', '.env', 'dist', 'build', '.DS_Store']
1921
+ const unsafeUntracked = untracked.filter(f =>
1922
+ NOCOMMIT_PATTERNS.some(p => f === p || f.startsWith(p + '/'))
1923
+ )
1924
+ const safeUntracked = untracked.filter(f =>
1925
+ !NOCOMMIT_PATTERNS.some(p => f === p || f.startsWith(p + '/'))
1926
+ )
1927
+
1928
+ // ── Build commit message ──────────────────────────────────────────────────
1929
+ // Use task branch name for type inference if available (more reliable than current branch)
1930
+ const branchForType = taskBranch || currentBranch
1931
+ const branchLower = branchForType.toLowerCase()
1932
+ const diffLower = diffStat.toLowerCase()
1933
+ let commitType = 'chore'
1934
+ if (branchLower.startsWith('feature/') || branchLower.startsWith('feat/')) commitType = 'feat'
1935
+ else if (branchLower.startsWith('fix/') || branchLower.startsWith('hotfix/')) commitType = 'fix'
1936
+ else if (branchLower.startsWith('refactor/')) commitType = 'refactor'
1937
+ else if (branchLower.startsWith('docs/')) commitType = 'docs'
1938
+ else if (branchLower.startsWith('test/')) commitType = 'test'
1939
+ else if (diffLower.includes('test') || diffLower.includes('spec')) commitType = 'test'
1940
+ else if (task) {
1941
+ const t = (task.title || '').toLowerCase()
1942
+ if (/\b(fix|bug|hotfix|patch)\b/.test(t)) commitType = 'fix'
1943
+ else if (/\b(refactor|cleanup)\b/.test(t)) commitType = 'refactor'
1944
+ else commitType = 'feat'
1945
+ }
1946
+
1947
+ const scope = task?.key?.toLowerCase() || ''
1948
+ let description = task
1949
+ ? task.title.toLowerCase().replace(/[^a-z0-9 ]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 60)
1950
+ : modified.length === 1
1951
+ ? `update ${modified[0].split('/').pop()}`
1952
+ : `update ${modified.length} file(s)`
1953
+
1954
+ const commitMsg = scope ? `${commitType}(${scope}): ${description}` : `${commitType}: ${description}`
1955
+
1956
+ // ── Build add command — exclude unsafe untracked ──────────────────────────
1957
+ const trackedFiles = [...modified, ...safeUntracked]
1958
+ const addCmd = unsafeUntracked.length > 0 && (unstaged.length > 0 || safeUntracked.length > 0)
1959
+ ? trackedFiles.map(f => `git add "${f}"`).join('\n') // explicit adds — skip unsafe
1960
+ : unstaged.length > 0 || safeUntracked.length > 0
1961
+ ? 'git add .'
1962
+ : null // everything already staged
1963
+
1964
+ const changedFilesList = [
1965
+ ...staged.map(f => `staged: ${f.file}`),
1966
+ ...unstaged.map(f => `unstaged: ${f.file}`),
1967
+ ...safeUntracked.map(f => `untracked: ${f}`),
1968
+ ...unsafeUntracked.map(f => `⚠️ SKIP: ${f} ← do not commit this`),
1969
+ ]
1970
+
1971
+ // ── PREVIEW (confirmed=false) ─────────────────────────────────────────────
1972
+ if (!confirmed) {
1973
+ // Case 1: branch mismatch — must resolve before anything else
1974
+ if (branchMismatch) {
1975
+ const stashMsg = `wip: ${scope || currentBranch} — switching to ${taskBranch}`
1976
+ return text({
1977
+ situation: {
1978
+ currentBranch,
1979
+ taskBranch,
1980
+ message: `You are on "${currentBranch}" but TASK-${task?.key} is linked to "${taskBranch}". Choose how to handle your local changes before committing.`,
1981
+ },
1982
+ changedFiles: changedFilesList,
1983
+ unsafeUntrackedWarning: unsafeUntracked.length
1984
+ ? `These paths should NOT be committed — add them to .gitignore: ${unsafeUntracked.join(', ')}`
1985
+ : null,
1986
+ options: {
1987
+ A: {
1988
+ branchAction: 'switch_then_commit',
1989
+ description: 'Switch to the task branch now, bring your changes, then commit (recommended)',
1990
+ commands: [
1991
+ `git stash push -m "${stashMsg}"`,
1992
+ `git fetch origin`,
1993
+ `git checkout ${taskBranch}`,
1994
+ `git stash pop`,
1995
+ addCmd,
1996
+ `git commit -m "${commitMsg}"`,
1997
+ `git push origin ${taskBranch}`,
1998
+ ].filter(Boolean),
1999
+ },
2000
+ B: {
2001
+ branchAction: 'stash_for_later',
2002
+ description: 'Stash changes now and commit on the task branch later',
2003
+ commands: [
2004
+ `git stash push -m "${stashMsg}"`,
2005
+ `# Later: git checkout ${taskBranch} && git stash pop`,
2006
+ ],
2007
+ },
2008
+ C: {
2009
+ branchAction: 'commit_here',
2010
+ description: `Commit on "${currentBranch}" as-is${onProtected ? ' ⚠️ THIS IS A PROTECTED BRANCH' : ''}`,
2011
+ warning: onProtected
2012
+ ? `"${currentBranch}" is a protected branch. Committing here directly bypasses the PR review process. Only do this for base repo changes (e.g. .gitignore, root README) that don't belong to any feature branch.`
2013
+ : null,
2014
+ commands: [addCmd, `git commit -m "${commitMsg}"`, `git push origin ${currentBranch}`].filter(Boolean),
2015
+ },
2016
+ },
2017
+ requiresConfirmation: true,
2018
+ message: `Call commit_helper again with confirmed=true and branchAction set to "switch_then_commit", "stash_for_later", or "commit_here".`,
2019
+ })
2020
+ }
2021
+
2022
+ // Case 2: on correct branch — standard preview
2023
+ const pushCmd = `git push origin ${currentBranch}`
2024
+ return text({
2025
+ preview: {
2026
+ suggestedMessage: commitMsg,
2027
+ type: commitType,
2028
+ scope,
2029
+ currentBranch,
2030
+ taskBranch: taskBranch || '(none linked)',
2031
+ onCorrectBranch: !branchMismatch,
2032
+ changedFiles: changedFilesList,
2033
+ },
2034
+ unsafeUntrackedWarning: unsafeUntracked.length
2035
+ ? `These paths should NOT be committed — add them to .gitignore first: ${unsafeUntracked.join(', ')}`
2036
+ : null,
2037
+ protectedBranchWarning: onProtected
2038
+ ? `⚠️ You are on "${currentBranch}" — a protected branch. Consider committing on a feature branch instead.`
2039
+ : null,
2040
+ commands: [addCmd, `git commit -m "${commitMsg}"`, pushCmd].filter(Boolean),
2041
+ requiresConfirmation: true,
2042
+ message: `Suggested: "${commitMsg}". Call commit_helper again with confirmed=true to get the final commands.`,
2043
+ })
2044
+ }
2045
+
2046
+ // ── CONFIRMED (confirmed=true) ────────────────────────────────────────────
2047
+ if (branchMismatch && !branchAction) {
2048
+ return text({
2049
+ blocked: true,
2050
+ message: `Branch mismatch detected. You must set branchAction to one of: "switch_then_commit", "stash_for_later", "commit_here". Call commit_helper with confirmed=false first to see the options.`,
2051
+ })
2052
+ }
2053
+
2054
+ const stashMsg = `wip: ${scope || currentBranch} — switching to ${taskBranch}`
2055
+
2056
+ if (branchAction === 'switch_then_commit') {
2057
+ return text({
2058
+ plan: 'switch_then_commit',
2059
+ commands: [
2060
+ `git stash push -m "${stashMsg}"`,
2061
+ `git fetch origin`,
2062
+ `git checkout ${taskBranch}`,
2063
+ `git stash pop`,
2064
+ addCmd,
2065
+ `git commit -m "${commitMsg}"`,
2066
+ `git push origin ${taskBranch}`,
2067
+ ].filter(Boolean),
2068
+ commitMessage: commitMsg,
2069
+ message: `Run these commands in order. Your changes will land on "${taskBranch}" where they belong.`,
2070
+ nextStep: `After pushing, call raise_pr to open the pull request for ${task?.key}.`,
2071
+ unsafeUntrackedWarning: unsafeUntracked.length
2072
+ ? `These will be in your working tree after stash pop — do NOT git add them: ${unsafeUntracked.join(', ')}`
2073
+ : null,
2074
+ })
2075
+ }
2076
+
2077
+ if (branchAction === 'stash_for_later') {
2078
+ return text({
2079
+ plan: 'stash_for_later',
2080
+ commands: [
2081
+ `git stash push -m "${stashMsg}"`,
2082
+ ],
2083
+ message: `Your changes are stashed. When you're ready to commit:`,
2084
+ resumeCommands: [
2085
+ `git checkout ${taskBranch}`,
2086
+ `git stash pop`,
2087
+ addCmd,
2088
+ `git commit -m "${commitMsg}"`,
2089
+ `git push origin ${taskBranch}`,
2090
+ ].filter(Boolean),
2091
+ commitMessage: commitMsg,
2092
+ })
2093
+ }
2094
+
2095
+ // commit_here (or no mismatch)
2096
+ const targetBranch = currentBranch
2097
+ const pushCmd = `git push origin ${targetBranch}`
2098
+ return text({
2099
+ commitMessage: commitMsg,
2100
+ commands: [addCmd, `git commit -m "${commitMsg}"`, pushCmd].filter(Boolean),
2101
+ changedFiles: changedFilesList,
2102
+ protectedBranchWarning: onProtected
2103
+ ? `⚠️ Committing directly to "${targetBranch}". Only appropriate for base repo changes that don't belong to a feature branch.`
2104
+ : null,
2105
+ unsafeUntrackedWarning: unsafeUntracked.length
2106
+ ? `These were excluded from git add — add them to .gitignore: ${unsafeUntracked.join(', ')}`
2107
+ : null,
2108
+ message: `Copy-paste these commands in order.`,
2109
+ nextStep: taskBranch && !branchMismatch ? `After pushing, call raise_pr to open the pull request.` : null,
2110
+ })
2111
+ }
2112
+ )
2113
+
2114
+ // ── branch_cleanup ────────────────────────────────────────────────────────────
2115
+ server.tool(
2116
+ 'branch_cleanup',
2117
+ `Find merged branches that are safe to delete — both on GitHub and locally.
2118
+
2119
+ Checks GitHub branches against tasks that have been merged (github.mergedAt set).
2120
+ Also runs "git branch --merged origin/main" locally to find stale local branches.
2121
+
2122
+ Set confirmed=false to preview the list, confirmed=true to get the exact delete commands.
2123
+
2124
+ Use this when the developer says "clean up branches", "delete merged branches", or after a sprint ends.`,
2125
+ {
2126
+ projectId: z.string().describe("Project's MongoDB ObjectId"),
2127
+ repoPath: z.string().optional().describe('Absolute path to the local git repo'),
2128
+ confirmed: z.boolean().optional().default(false).describe('Set true to get the delete commands'),
2129
+ },
2130
+ async ({ projectId, repoPath, confirmed = false }) => {
2131
+ if (scopedProjectId && projectId !== scopedProjectId) {
2132
+ return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
2133
+ }
2134
+
2135
+ // Fetch GitHub branches
2136
+ const branchesRes = await api.get(`/api/projects/${projectId}/github/branches`)
2137
+ if (!branchesRes?.success) return errorText('Could not fetch GitHub branches')
2138
+ const remoteBranches = (branchesRes.data.branches || []).map(b => b.name || b)
2139
+
2140
+ // Fetch all tasks to find which branches are merged
2141
+ const projectRes = await api.get(`/api/projects/${projectId}`)
2142
+ const tasks = projectRes?.data?.project?.tasks || []
2143
+ const defaultBranch = projectRes?.data?.project?.githubDefaultBranch || 'main'
2144
+
2145
+ // Branches linked to merged tasks
2146
+ const mergedTaskBranches = new Set(
2147
+ tasks
2148
+ .filter(t => t.github?.mergedAt && t.github?.headBranch)
2149
+ .map(t => t.github.headBranch)
2150
+ )
2151
+
2152
+ // Remote branches safe to delete (merged task branch, not the default branch)
2153
+ const remoteToDelete = remoteBranches.filter(b =>
2154
+ mergedTaskBranches.has(b) && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev'
2155
+ )
2156
+
2157
+ // Local merged branches
2158
+ const cwd = repoPath || process.cwd()
2159
+ let localMerged = []
2160
+ try {
2161
+ const localRaw = runGit(`branch --merged origin/${defaultBranch}`, cwd)
2162
+ localMerged = localRaw
2163
+ .split('\n')
2164
+ .map(b => b.replace(/^\*?\s+/, '').trim())
2165
+ .filter(b => b && b !== defaultBranch && b !== 'main' && b !== 'master' && b !== 'dev')
2166
+ } catch { /* git may not be available or no upstream */ }
2167
+
2168
+ if (!confirmed) {
2169
+ return text({
2170
+ preview: {
2171
+ remoteBranchesToDelete: remoteToDelete,
2172
+ localBranchesToDelete: localMerged,
2173
+ defaultBranch,
2174
+ remoteCount: remoteToDelete.length,
2175
+ localCount: localMerged.length,
2176
+ },
2177
+ requiresConfirmation: true,
2178
+ message: remoteToDelete.length === 0 && localMerged.length === 0
2179
+ ? 'No merged branches found to clean up. Repository is already clean.'
2180
+ : `Found ${remoteToDelete.length} remote and ${localMerged.length} local branch(es) to delete. Call branch_cleanup again with confirmed=true to get the delete commands.`,
2181
+ safetyNote: 'Only branches linked to tasks with a merged PR are included. Default branch, main, master, and dev are always excluded.',
2182
+ })
2183
+ }
2184
+
2185
+ const remoteDeleteCmds = remoteToDelete.map(b => `git push origin --delete ${b}`)
2186
+ const localDeleteCmds = localMerged.map(b => `git branch -d ${b}`)
2187
+ const allCmds = [
2188
+ '# Fetch latest remote state first',
2189
+ `git fetch origin --prune`,
2190
+ '',
2191
+ remoteDeleteCmds.length ? '# Delete merged remote branches' : null,
2192
+ ...remoteDeleteCmds,
2193
+ '',
2194
+ localDeleteCmds.length ? '# Delete merged local branches' : null,
2195
+ ...localDeleteCmds,
2196
+ ].filter(v => v !== null)
2197
+
2198
+ return text({
2199
+ commands: allCmds,
2200
+ remoteBranchesDeleted: remoteToDelete,
2201
+ localBranchesDeleted: localMerged,
2202
+ message: `Run the commands above to clean up ${remoteToDelete.length} remote and ${localMerged.length} local branch(es).`,
2203
+ tip: 'git fetch --prune also removes stale remote-tracking refs automatically.',
2204
+ })
2205
+ }
2206
+ )
1473
2207
  }
1474
2208
 
1475
2209
  function registerAdminTools(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.5.7",
3
+ "version": "1.6.1",
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",