internaltool-mcp 1.6.10 → 1.6.17

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 +306 -31
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -302,58 +302,213 @@ function registerTaskTools(server, { isAdmin, scopedProjectId }) {
302
302
 
303
303
  server.tool(
304
304
  'park_task',
305
- `Pause a task and save your current work context.
306
- IMPORTANT: Before calling this, run "git diff HEAD" and "git status" in the terminal to see what has changed.
307
- Use that output to write precise summary/remaining/blockers fields — not generic text.
308
- This is the developer's saved mental state; make it detailed enough to resume cold tomorrow.
305
+ `Pause a task and hand it off safely so another developer can continue without losing any work.
309
306
 
310
- Set confirmed=false first to preview, then confirmed=true to actually save.`,
307
+ WHAT THIS DOES AUTOMATICALLY (confirmed=true):
308
+ 1. Verifies all changes are committed and pushed — blocks if not
309
+ 2. Auto-pushes any unpushed commits to remote
310
+ 3. Saves your park note (summary, remaining, blockers) to the task
311
+ 4. Deletes the cursor rules file (.cursor/rules/<task>.mdc) so it doesn't bleed into other tasks
312
+ 5. Posts a comment on the task with your handoff notes (visible to Dev B)
313
+ 6. Notifies all project members that this task is parked and ready for pickup
314
+
315
+ Set confirmed=false first to preview, then confirmed=true to execute everything.`,
311
316
  {
312
317
  taskId: z.string().describe("Task's MongoDB ObjectId"),
313
318
  summary: z.string().optional().describe('What was built/changed — include file names and what was done'),
314
319
  remaining: z.string().optional().describe('Specific next steps to complete this task'),
315
320
  blockers: z.string().optional().describe('Anything blocking progress'),
316
321
  confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
322
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
317
323
  },
318
- async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false }) => {
324
+ async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false, repoPath }) => {
325
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
326
+ const task = taskRes?.data?.task
327
+
328
+ const cwd = repoPath || process.cwd()
329
+ const repoRoot = findRepoRoot(cwd)
330
+
331
+ // ── Git safety checks ──────────────────────────────────────────────────
332
+ let uncommitted = false
333
+ let unpushedCount = 0
334
+ let currentBranch = ''
335
+ if (repoRoot) {
336
+ try {
337
+ const porcelain = runGit('status --porcelain=v1', repoRoot)
338
+ uncommitted = parseGitStatus(porcelain).localState === 'modified'
339
+ } catch { /* non-fatal */ }
340
+ try {
341
+ currentBranch = runGit('branch --show-current', repoRoot).trim()
342
+ if (currentBranch) {
343
+ try { runGit('fetch origin', repoRoot) } catch { /* no network */ }
344
+ unpushedCount = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, repoRoot).trim(), 10) || 0
345
+ }
346
+ } catch { /* remote branch may not exist */ }
347
+ }
348
+
349
+ if (uncommitted) {
350
+ return text({
351
+ blocked: true,
352
+ reason: 'You have uncommitted local changes.',
353
+ fix: 'Commit your changes first, then call park_task again.',
354
+ commands: ['git add .', 'git commit -m "wip: parking task — <describe what you did>"'],
355
+ message: 'Cannot park until all changes are committed.',
356
+ })
357
+ }
358
+
319
359
  if (!confirmed) {
320
360
  return text({
321
361
  preview: {
322
- action: 'park_task',
323
- taskId,
324
- willSave: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
362
+ task: task ? { key: task.key, title: task.title, branch: task.github?.headBranch || null } : null,
363
+ parkNote: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
364
+ willAutomate: [
365
+ unpushedCount > 0 ? `Push ${unpushedCount} unpushed commit(s) to remote` : 'Branch is already up to date on remote',
366
+ 'Save park note to task',
367
+ task?.cursorRules?.trim() ? 'Delete cursor rules file (.cursor/rules/' + task.key?.toLowerCase() + '.mdc)' : null,
368
+ 'Post handoff comment on the task',
369
+ 'Notify project members',
370
+ ].filter(Boolean),
325
371
  },
326
372
  requiresConfirmation: true,
327
- message: 'Review the park note above. Call park_task again with confirmed=true to save it.',
373
+ message: 'Review the preview above, then call park_task again with confirmed=true to execute.',
328
374
  })
329
375
  }
330
- return call(() => api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers }))
376
+
377
+ // ── Auto-push unpushed commits ─────────────────────────────────────────
378
+ let pushed = false
379
+ if (repoRoot && currentBranch && unpushedCount > 0) {
380
+ try {
381
+ runGit(`push origin ${currentBranch}`, repoRoot)
382
+ pushed = true
383
+ } catch (e) {
384
+ return text({ blocked: true, reason: `Auto-push failed: ${e.message.split('\n')[0]}. Push manually then call park_task again.` })
385
+ }
386
+ }
387
+
388
+ // ── Save park note + server-side comment & notifications ──────────────
389
+ await api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers })
390
+
391
+ // ── Delete cursor rules file ───────────────────────────────────────────
392
+ let cursorRulesCleared = null
393
+ if (task?.cursorRules?.trim()) {
394
+ cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
395
+ }
396
+
397
+ return text({
398
+ parked: true,
399
+ task: { key: task?.key, title: task?.title, branch: task?.github?.headBranch || null },
400
+ autoPushed: pushed ? `${unpushedCount} commit(s) pushed to origin/${currentBranch}` : 'No push needed',
401
+ cursorRulesCleared: cursorRulesCleared ? `Deleted ${cursorRulesCleared}` : null,
402
+ commentPosted: true,
403
+ teamNotified: true,
404
+ message: 'Task parked. Project members have been notified. Dev B can run unpark_task to continue.',
405
+ })
331
406
  }
332
407
  )
333
408
 
334
409
  server.tool(
335
410
  'unpark_task',
336
- 'Resume a parked task. Set confirmed=false first to preview, then confirmed=true to execute.',
411
+ `Pick up a parked task and get fully set up to continue the previous developer's work.
412
+
413
+ WHAT THIS DOES AUTOMATICALLY (confirmed=true):
414
+ 1. Shows the full park note from the previous developer before doing anything
415
+ 2. Shows the last 5 commits on the branch so you know the exact state of the code
416
+ 3. Shows recent task comments so you have full context
417
+ 4. Runs: git fetch origin + git checkout <branch> + git pull (switches your local repo)
418
+ 5. Restores the cursor rules file (.cursor/rules/<task>.mdc) so Cursor follows task rules
419
+ 6. Posts a comment that you picked up the task
420
+ 7. Notifies the previous developer that you have taken over
421
+
422
+ Set confirmed=false first to read everything, then confirmed=true to execute.`,
337
423
  {
338
424
  taskId: z.string().describe("Task's MongoDB ObjectId"),
339
- confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
425
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the park note'),
426
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
340
427
  },
341
- async ({ taskId, confirmed = false }) => {
428
+ async ({ taskId, confirmed = false, repoPath }) => {
429
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
430
+ const task = taskRes?.data?.task
431
+ const branch = task?.github?.headBranch || null
432
+
433
+ // ── Fetch recent commits on the branch ────────────────────────────────
434
+ let recentCommits = []
435
+ if (branch) {
436
+ try {
437
+ const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=5&branch=${branch}`)
438
+ recentCommits = commitsRes?.data?.commits || []
439
+ } catch { /* non-fatal */ }
440
+ }
441
+
442
+ // ── Fetch recent task comments ─────────────────────────────────────────
443
+ let recentComments = []
444
+ try {
445
+ const commentsRes = await api.get(`/api/tasks/${taskId}/comments`)
446
+ recentComments = (commentsRes?.data?.comments || []).slice(-5).map(c => ({
447
+ author: c.author?.name || c.author?.email || 'unknown',
448
+ body: c.body?.slice(0, 300),
449
+ at: c.createdAt,
450
+ }))
451
+ } catch { /* non-fatal */ }
452
+
342
453
  if (!confirmed) {
343
- const taskRes = await api.get(`/api/tasks/${taskId}`)
344
- const task = taskRes?.data?.task
345
454
  return text({
346
- preview: {
347
- action: 'unpark_task',
348
- taskId,
349
- task: task ? { key: task.key, title: task.title, parkNote: task.parkNote } : null,
350
- willDo: 'Clear park note and mark task as active again',
455
+ HANDOFF_CONTEXT: {
456
+ task: { key: task?.key, title: task?.title, priority: task?.priority },
457
+ parkNote: task?.parkNote || null,
458
+ branch,
459
+ recentCommits: recentCommits.length ? recentCommits.map(c => `${c.sha?.slice(0,7)} ${c.commit?.message?.split('\n')[0]} ${c.commit?.author?.name}`) : ['No commits found'],
460
+ recentComments,
351
461
  },
462
+ willAutomate: branch ? [
463
+ `git fetch origin`,
464
+ `git checkout ${branch}`,
465
+ `git pull origin ${branch}`,
466
+ task?.cursorRules?.trim() ? `Restore .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
467
+ 'Post "picked up" comment on task',
468
+ 'Notify previous developer',
469
+ ].filter(Boolean) : ['No branch linked — create one with create_branch after unparking'],
352
470
  requiresConfirmation: true,
353
- message: 'Call unpark_task again with confirmed=true to resume this task.',
471
+ message: 'Read the handoff context above. Call unpark_task again with confirmed=true to execute all steps automatically.',
354
472
  })
355
473
  }
356
- return call(() => api.patch(`/api/tasks/${taskId}/unpark`, {}))
474
+
475
+ // ── Auto-execute git branch switch ────────────────────────────────────
476
+ const cwd = repoPath || process.cwd()
477
+ const repoRoot = findRepoRoot(cwd)
478
+ let gitResult = null
479
+
480
+ if (repoRoot && branch) {
481
+ try {
482
+ runGit('fetch origin', repoRoot)
483
+ runGit(`checkout ${branch}`, repoRoot)
484
+ runGit(`pull origin ${branch}`, repoRoot)
485
+ gitResult = { switched: true, branch }
486
+ } catch (e) {
487
+ gitResult = { switched: false, error: e.message.split('\n')[0], manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}`] }
488
+ }
489
+ }
490
+
491
+ // ── Clear park note + server-side comment & notification ──────────────
492
+ await api.patch(`/api/tasks/${taskId}/unpark`, {})
493
+
494
+ // ── Restore cursor rules file ──────────────────────────────────────────
495
+ let cursorRulesFile = null
496
+ if (task?.cursorRules?.trim()) {
497
+ cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
498
+ }
499
+
500
+ return text({
501
+ unparked: true,
502
+ task: { key: task?.key, title: task?.title },
503
+ git: gitResult || { switched: false, reason: 'No branch linked or repo not found' },
504
+ cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
505
+ commentPosted: true,
506
+ previousDevNotified: true,
507
+ parkNote: task?.parkNote || null,
508
+ message: gitResult?.switched
509
+ ? `You are now on branch "${branch}". Cursor rules restored. Start coding from where ${task?.parkNote?.parkedBy ? 'the previous developer' : 'you'} left off.`
510
+ : `Branch switch failed — see git.manualSteps. Cursor rules restored.`,
511
+ })
357
512
  }
358
513
  )
359
514
  }
@@ -855,10 +1010,18 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
855
1010
  if (!taskRes?.success) return errorText('Task not found')
856
1011
  const task = taskRes.data.task
857
1012
 
858
- const suggestedBranch = `feature/${task.key?.toLowerCase()}-${task.title
859
- .toLowerCase()
860
- .replace(/[^a-z0-9]+/g, '-')
861
- .slice(0, 40)}`
1013
+ // Include developer name in branch so it's clear who created it
1014
+ let devSlug = ''
1015
+ try {
1016
+ const meRes = await api.get('/api/auth/me')
1017
+ const devName = meRes?.data?.user?.name || meRes?.data?.name || ''
1018
+ devSlug = devName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 15)
1019
+ } catch { /* non-fatal */ }
1020
+
1021
+ const titleSlug = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 35)
1022
+ const suggestedBranch = devSlug
1023
+ ? `${devSlug}/feature/${task.key?.toLowerCase()}-${titleSlug}`
1024
+ : `feature/${task.key?.toLowerCase()}-${titleSlug}`
862
1025
 
863
1026
  const hasReadme = typeof task.readmeMarkdown === 'string' && task.readmeMarkdown.trim().length > 0
864
1027
  const hasCursorRules = typeof task.cursorRules === 'string' && task.cursorRules.trim().length > 0
@@ -904,6 +1067,39 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
904
1067
  ]
905
1068
  }
906
1069
 
1070
+ // ── Simultaneous work lock ──────────────────────────────────────────────────
1071
+ // If the task is already in_progress with a branch, warn before proceeding.
1072
+ // This prevents two developers from unknowingly working on the same task.
1073
+ const alreadyActive = task.column === 'in_progress' && !!task.github?.headBranch
1074
+ const activeAssignees = (task.assignees || []).map(a => a.name || a.email || a.toString())
1075
+
1076
+ if (!confirmed && alreadyActive) {
1077
+ return text({
1078
+ warning: {
1079
+ type: 'simultaneous_work_detected',
1080
+ message: `⚠️ This task is already in progress on branch "${task.github.headBranch}".`,
1081
+ assignees: activeAssignees.length ? activeAssignees : ['(unassigned)'],
1082
+ branch: task.github.headBranch,
1083
+ parkNote: task.parkNote || null,
1084
+ advice: activeAssignees.length
1085
+ ? `Check with ${activeAssignees.join(', ')} before taking over. If they have parked this task their changes are pushed — checkout their branch instead of starting fresh.`
1086
+ : `Someone may have been working on this. Check the branch before starting fresh.`,
1087
+ checkoutSteps: [
1088
+ 'git fetch origin',
1089
+ `git checkout ${task.github.headBranch}`,
1090
+ `git pull origin ${task.github.headBranch}`,
1091
+ ],
1092
+ },
1093
+ brief: {
1094
+ key: task.key,
1095
+ title: task.title,
1096
+ priority: task.priority,
1097
+ },
1098
+ requiresConfirmation: true,
1099
+ message: `Task is already in progress. Read the warning above. If you still want to take over, call kickoff_task again with confirmed=true.`,
1100
+ })
1101
+ }
1102
+
907
1103
  if (!confirmed) {
908
1104
  return text({
909
1105
  CURSOR_RULES: hasCursorRules
@@ -1308,6 +1504,32 @@ function parseGitStatus(porcelain) {
1308
1504
  return { staged, unstaged, untracked, modified, localState }
1309
1505
  }
1310
1506
 
1507
+ /**
1508
+ * Returns how many commits the current branch is behind/ahead of its base branch,
1509
+ * and whether there are uncommitted changes. Fetches from origin first.
1510
+ * Returns null on any git error (e.g. no remote configured).
1511
+ */
1512
+ function getBranchSyncStatus(cwd, baseBranch = 'main') {
1513
+ try {
1514
+ runGit('fetch origin', cwd)
1515
+ } catch { /* no remote or no network — continue with local data */ }
1516
+ try {
1517
+ const behind = parseInt(runGit(`rev-list HEAD..origin/${baseBranch} --count`, cwd).trim(), 10) || 0
1518
+ const unpushed = parseInt(runGit(`rev-list origin/${baseBranch}..HEAD --count`, cwd).trim(), 10) || 0
1519
+ // Check if the remote tracking branch for the feature branch exists and has unpushed commits
1520
+ let unpushedOnBranch = 0
1521
+ try {
1522
+ const currentBranch = runGit('branch --show-current', cwd).trim()
1523
+ unpushedOnBranch = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, cwd).trim(), 10) || 0
1524
+ } catch { /* remote branch may not exist yet — all commits are unpushed */ }
1525
+ const porcelain = runGit('status --porcelain=v1', cwd)
1526
+ const { localState } = parseGitStatus(porcelain)
1527
+ return { behind, unpushed, unpushedOnBranch, localState, baseBranch }
1528
+ } catch {
1529
+ return null
1530
+ }
1531
+ }
1532
+
1311
1533
  // ── Git Workflow Tools ─────────────────────────────────────────────────────────
1312
1534
  // All write operations require confirmed=true after showing a preview.
1313
1535
  // This gives developers full visibility before anything is executed.
@@ -1598,7 +1820,18 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
1598
1820
  .replace(/[^a-z0-9]+/g, '-')
1599
1821
  .replace(/^-|-$/g, '')
1600
1822
  .slice(0, 35)
1601
- const branchName = `${prefix}/${task.key.toLowerCase()}-${slug}`
1823
+
1824
+ // Include developer name so it's clear who created the branch
1825
+ let devSlug = ''
1826
+ try {
1827
+ const meRes = await api.get('/api/auth/me')
1828
+ const devName = meRes?.data?.user?.name || meRes?.data?.name || ''
1829
+ devSlug = devName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 15)
1830
+ } catch { /* non-fatal */ }
1831
+
1832
+ const branchName = devSlug
1833
+ ? `${devSlug}/${prefix}/${task.key.toLowerCase()}-${slug}`
1834
+ : `${prefix}/${task.key.toLowerCase()}-${slug}`
1602
1835
 
1603
1836
  // Auto-detect local git state
1604
1837
  let gitState = null
@@ -2028,8 +2261,33 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2028
2261
  ].filter(v => v !== null).join('\n')
2029
2262
 
2030
2263
  if (!confirmed) {
2264
+ // ── Pre-flight: check branch sync status before allowing PR ──
2265
+ const cwd = repoPath || process.cwd()
2266
+ const repoRoot = findRepoRoot(cwd)
2267
+ const sync = repoRoot ? getBranchSyncStatus(repoRoot) : null
2268
+
2269
+ const preflight = { status: 'ok', warnings: [], blockers: [] }
2270
+ if (sync) {
2271
+ if (sync.behind > 0) {
2272
+ preflight.blockers.push(
2273
+ `Branch is ${sync.behind} commit(s) behind origin/${sync.baseBranch}. Rebase before raising PR to avoid merge conflicts: git fetch origin && git rebase origin/${sync.baseBranch}`
2274
+ )
2275
+ }
2276
+ if (sync.unpushedOnBranch > 0) {
2277
+ preflight.blockers.push(
2278
+ `You have ${sync.unpushedOnBranch} local commit(s) not pushed to remote. Push first: git push origin ${headBranch}`
2279
+ )
2280
+ }
2281
+ if (sync.localState === 'modified') {
2282
+ preflight.warnings.push('You have uncommitted local changes. Commit or stash them before the PR is merged.')
2283
+ }
2284
+ if (preflight.blockers.length > 0) preflight.status = 'blocked'
2285
+ else if (preflight.warnings.length > 0) preflight.status = 'warning'
2286
+ }
2287
+
2031
2288
  return text({
2032
- preview: {
2289
+ preflight,
2290
+ preview: preflight.status === 'blocked' ? null : {
2033
2291
  action: 'raise_pr',
2034
2292
  prTitle,
2035
2293
  prBody: bodyParts,
@@ -2037,8 +2295,10 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2037
2295
  draft,
2038
2296
  task: { key: task.key, title: task.title },
2039
2297
  },
2040
- requiresConfirmation: true,
2041
- message: `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2298
+ requiresConfirmation: preflight.status !== 'blocked',
2299
+ message: preflight.status === 'blocked'
2300
+ ? `⛔ Cannot raise PR — fix the blockers above first.`
2301
+ : `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2042
2302
  })
2043
2303
  }
2044
2304
 
@@ -2246,6 +2506,20 @@ branchAction values (only needed when current branch ≠ task branch):
2246
2506
 
2247
2507
  // Case 2: on correct branch — standard preview
2248
2508
  const pushCmd = `git push origin ${currentBranch}`
2509
+
2510
+ // Check if branch is behind base — warn developer to rebase before more commits pile up
2511
+ let divergenceWarning = null
2512
+ try {
2513
+ runGit('fetch origin', cwd)
2514
+ const repoRoot = findRepoRoot(cwd)
2515
+ if (repoRoot) {
2516
+ const behind = parseInt(runGit(`rev-list HEAD..origin/main --count`, repoRoot).trim(), 10) || 0
2517
+ if (behind > 0) {
2518
+ divergenceWarning = `⚠️ Your branch is ${behind} commit(s) behind origin/main. Rebase now to prevent merge conflicts later:\n git fetch origin && git rebase origin/main\nThen re-run your commits.`
2519
+ }
2520
+ }
2521
+ } catch { /* non-fatal */ }
2522
+
2249
2523
  return text({
2250
2524
  preview: {
2251
2525
  suggestedMessage: commitMsg,
@@ -2256,6 +2530,7 @@ branchAction values (only needed when current branch ≠ task branch):
2256
2530
  onCorrectBranch: !branchMismatch,
2257
2531
  changedFiles: changedFilesList,
2258
2532
  },
2533
+ divergenceWarning,
2259
2534
  unsafeUntrackedWarning: unsafeUntracked.length
2260
2535
  ? `These paths should NOT be committed — add them to .gitignore first: ${unsafeUntracked.join(', ')}`
2261
2536
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.6.10",
3
+ "version": "1.6.17",
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",