internaltool-mcp 1.6.14 → 1.6.19

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 +214 -67
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -302,13 +302,17 @@ 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 so another developer can safely pick it up.
305
+ `Pause a task and hand it off safely so another developer can continue without losing any work.
306
306
 
307
- SAFETY REQUIREMENT: All local changes must be committed and pushed before parking.
308
- If there are uncommitted or unpushed changes, this tool will block and tell you to push first.
309
- This ensures no work is lost when someone else picks up the task.
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
310
314
 
311
- Set confirmed=false first to preview, then confirmed=true to actually save.`,
315
+ Set confirmed=false first to preview, then confirmed=true to execute everything.`,
312
316
  {
313
317
  taskId: z.string().describe("Task's MongoDB ObjectId"),
314
318
  summary: z.string().optional().describe('What was built/changed — include file names and what was done'),
@@ -321,111 +325,209 @@ Set confirmed=false first to preview, then confirmed=true to actually save.`,
321
325
  const taskRes = await api.get(`/api/tasks/${taskId}`)
322
326
  const task = taskRes?.data?.task
323
327
 
324
- // ── Safety check: block if there are uncommitted or unpushed changes ──
325
- const cwd = repoPath || process.cwd()
328
+ const cwd = repoPath || process.cwd()
326
329
  const repoRoot = findRepoRoot(cwd)
327
- let gitBlockers = []
330
+
331
+ // ── Git safety checks ──────────────────────────────────────────────────
332
+ let uncommitted = false
333
+ let unpushedCount = 0
334
+ let currentBranch = ''
328
335
  if (repoRoot) {
329
336
  try {
330
337
  const porcelain = runGit('status --porcelain=v1', repoRoot)
331
- const { localState } = parseGitStatus(porcelain)
332
- if (localState === 'modified') {
333
- gitBlockers.push('You have uncommitted local changes. Commit them first so your work is not lost:\n git add . && git commit -m "<your message>" && git push')
334
- }
338
+ uncommitted = parseGitStatus(porcelain).localState === 'modified'
335
339
  } catch { /* non-fatal */ }
336
340
  try {
337
- const currentBranch = runGit('branch --show-current', repoRoot).trim()
341
+ currentBranch = runGit('branch --show-current', repoRoot).trim()
338
342
  if (currentBranch) {
339
- runGit('fetch origin', repoRoot)
340
- const unpushed = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, repoRoot).trim(), 10) || 0
341
- if (unpushed > 0) {
342
- gitBlockers.push(`You have ${unpushed} unpushed commit(s) on "${currentBranch}". Push first so another developer can access them:\n git push origin ${currentBranch}`)
343
- }
343
+ try { runGit('fetch origin', repoRoot) } catch { /* no network */ }
344
+ unpushedCount = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, repoRoot).trim(), 10) || 0
344
345
  }
345
- } catch { /* remote branch may not exist — treat as unpushed */ }
346
- }
347
-
348
- if (gitBlockers.length > 0) {
349
- return text({
350
- blocked: true,
351
- reason: 'Cannot park task — changes are not fully committed and pushed.',
352
- gitBlockers,
353
- message: 'Fix the issues above, then call park_task again.',
354
- })
346
+ } catch { /* remote branch may not exist */ }
355
347
  }
356
348
 
357
349
  if (!confirmed) {
358
350
  return text({
359
351
  preview: {
360
- action: 'park_task',
361
- taskId,
362
352
  task: task ? { key: task.key, title: task.title, branch: task.github?.headBranch || null } : null,
363
- willSave: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
364
- cursorRulesNote: task?.cursorRules?.trim() ? `Cursor rules file (.cursor/rules/${task.key?.toLowerCase()}.mdc) will be removed while parked.` : null,
365
- handoffNote: task?.github?.headBranch
366
- ? `All work is pushed. Another developer can pick this up by running: git fetch origin && git checkout ${task.github.headBranch}`
367
- : null,
353
+ parkNote: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
354
+ willAutomate: [
355
+ uncommitted ? `Auto-commit uncommitted changes with message: "wip(${task?.key?.toLowerCase()}): ${(summary || 'parking task').slice(0, 60)}"` : null,
356
+ unpushedCount > 0 || uncommitted ? `Push branch to remote` : 'Branch is already up to date on remote',
357
+ 'Save park note to task',
358
+ task?.cursorRules?.trim() ? `Delete cursor rules file (.cursor/rules/${task.key?.toLowerCase()}.mdc)` : null,
359
+ 'Post handoff comment on the task',
360
+ 'Notify project members',
361
+ ].filter(Boolean),
368
362
  },
369
363
  requiresConfirmation: true,
370
- message: 'All changes are pushed. Review the park note above then call park_task again with confirmed=true.',
364
+ message: 'Review the preview above, then call park_task again with confirmed=true to execute.',
371
365
  })
372
366
  }
373
367
 
374
- const res = await api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers })
375
- // Remove cursor rules file while task is parked — another task may be active
368
+ // ── Auto-commit if there are uncommitted changes ───────────────────────
369
+ if (repoRoot && uncommitted) {
370
+ try {
371
+ const commitMsg = `wip(${task?.key?.toLowerCase() || 'task'}): ${(summary || 'parking task').slice(0, 60)}`
372
+ runGit('add -A', repoRoot)
373
+ runGit(`commit -m "${commitMsg}"`, repoRoot)
374
+ unpushedCount += 1 // just committed, so now there's something to push
375
+ } catch (e) {
376
+ return text({ blocked: true, reason: `Auto-commit failed: ${e.message.split('\n')[0]}. Commit manually then call park_task again.` })
377
+ }
378
+ }
379
+
380
+ // ── Auto-push unpushed commits ─────────────────────────────────────────
381
+ let pushed = false
382
+ if (repoRoot && currentBranch && unpushedCount > 0) {
383
+ try {
384
+ runGit(`push origin ${currentBranch}`, repoRoot)
385
+ pushed = true
386
+ } catch (e) {
387
+ return text({ blocked: true, reason: `Auto-push failed: ${e.message.split('\n')[0]}. Push manually then call park_task again.` })
388
+ }
389
+ }
390
+
391
+ // ── Save park note + server-side comment & notifications ──────────────
392
+ await api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers })
393
+
394
+ // ── Delete cursor rules file ───────────────────────────────────────────
376
395
  let cursorRulesCleared = null
377
396
  if (task?.cursorRules?.trim()) {
378
397
  cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
379
398
  }
399
+
380
400
  return text({
381
- ...(res?.data || {}),
382
- cursorRulesCleared,
383
- handoff: task?.github?.headBranch
384
- ? { branch: task.github.headBranch, checkoutSteps: ['git fetch origin', `git checkout ${task.github.headBranch}`] }
385
- : null,
401
+ parked: true,
402
+ task: { key: task?.key, title: task?.title, branch: task?.github?.headBranch || null },
403
+ autoPushed: pushed ? `${unpushedCount} commit(s) pushed to origin/${currentBranch}` : 'No push needed',
404
+ cursorRulesCleared: cursorRulesCleared ? `Deleted ${cursorRulesCleared}` : null,
405
+ commentPosted: true,
406
+ teamNotified: true,
407
+ message: 'Task parked. Project members have been notified. Dev B can run unpark_task to continue.',
386
408
  })
387
409
  }
388
410
  )
389
411
 
390
412
  server.tool(
391
413
  'unpark_task',
392
- 'Resume a parked task shows exactly what the previous developer left behind and what to do next.',
414
+ `Pick up a parked task and get fully set up to continue the previous developer's work.
415
+
416
+ WHAT THIS DOES AUTOMATICALLY (confirmed=true):
417
+ 1. Shows the full park note from the previous developer before doing anything
418
+ 2. Shows the last 5 commits on the branch so you know the exact state of the code
419
+ 3. Shows recent task comments so you have full context
420
+ 4. Runs: git fetch origin + git checkout <branch> + git pull (switches your local repo)
421
+ 5. Restores the cursor rules file (.cursor/rules/<task>.mdc) so Cursor follows task rules
422
+ 6. Posts a comment that you picked up the task
423
+ 7. Notifies the previous developer that you have taken over
424
+
425
+ Set confirmed=false first to read everything, then confirmed=true to execute.`,
393
426
  {
394
427
  taskId: z.string().describe("Task's MongoDB ObjectId"),
395
- confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
396
- repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to restore cursor rules file.'),
428
+ confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the park note'),
429
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
397
430
  },
398
431
  async ({ taskId, confirmed = false, repoPath }) => {
399
432
  const taskRes = await api.get(`/api/tasks/${taskId}`)
400
433
  const task = taskRes?.data?.task
401
434
  const branch = task?.github?.headBranch || null
435
+
436
+ // ── Fetch recent commits on the branch ────────────────────────────────
437
+ let recentCommits = []
438
+ if (branch) {
439
+ try {
440
+ const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=5&branch=${branch}`)
441
+ recentCommits = commitsRes?.data?.commits || []
442
+ } catch { /* non-fatal */ }
443
+ }
444
+
445
+ // ── Fetch recent task comments ─────────────────────────────────────────
446
+ let recentComments = []
447
+ try {
448
+ const commentsRes = await api.get(`/api/tasks/${taskId}/comments`)
449
+ recentComments = (commentsRes?.data?.comments || []).slice(-5).map(c => ({
450
+ author: c.author?.name || c.author?.email || 'unknown',
451
+ body: c.body?.slice(0, 300),
452
+ at: c.createdAt,
453
+ }))
454
+ } catch { /* non-fatal */ }
455
+
402
456
  if (!confirmed) {
403
457
  return text({
404
- preview: {
405
- action: 'unpark_task',
406
- taskId,
407
- task: task ? { key: task.key, title: task.title, column: task.column } : null,
408
- parkNote: task?.parkNote || null,
458
+ HANDOFF_CONTEXT: {
459
+ task: { key: task?.key, title: task?.title, priority: task?.priority },
460
+ parkNote: task?.parkNote || null,
409
461
  branch,
410
- handoffSteps: branch ? [
411
- 'git fetch origin',
412
- `git checkout ${branch}`,
413
- 'git pull origin ' + branch + ' # get any commits the previous developer pushed',
414
- ] : ['No branch linked yet — create one with create_branch'],
415
- cursorRulesNote: task?.cursorRules?.trim() ? `Cursor rules will be restored to .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
416
- willDo: 'Clear park note and mark task as active again',
462
+ recentCommits: recentCommits.length ? recentCommits.map(c => `${c.sha?.slice(0,7)} ${c.commit?.message?.split('\n')[0]} — ${c.commit?.author?.name}`) : ['No commits found'],
463
+ recentComments,
417
464
  },
465
+ willAutomate: branch ? [
466
+ `git fetch origin`,
467
+ `git checkout ${branch}`,
468
+ `git pull origin ${branch}`,
469
+ task?.cursorRules?.trim() ? `Restore .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
470
+ 'Post "picked up" comment on task',
471
+ 'Notify previous developer',
472
+ ].filter(Boolean) : ['No branch linked — create one with create_branch after unparking'],
418
473
  requiresConfirmation: true,
419
- message: 'Read the park note and handoff steps above, then call unpark_task again with confirmed=true to take ownership.',
474
+ message: 'Read the handoff context above. Call unpark_task again with confirmed=true to execute all steps automatically.',
420
475
  })
421
476
  }
422
- const res = await api.patch(`/api/tasks/${taskId}/unpark`, {})
423
- // Restore cursor rules file now that this task is active again
477
+
478
+ // ── Auto-execute git branch switch ────────────────────────────────────
479
+ const cwd = repoPath || process.cwd()
480
+ const repoRoot = findRepoRoot(cwd)
481
+ let gitResult = null
482
+
483
+ if (repoRoot && branch) {
484
+ try {
485
+ runGit('fetch origin', repoRoot)
486
+ runGit(`checkout ${branch}`, repoRoot)
487
+ runGit(`pull origin ${branch}`, repoRoot)
488
+
489
+ // Auto-rebase from main so Dev B starts on a clean, up-to-date branch
490
+ let rebaseResult = null
491
+ try {
492
+ const behind = parseInt(runGit(`rev-list HEAD..origin/main --count`, repoRoot).trim(), 10) || 0
493
+ if (behind > 0) {
494
+ runGit('rebase origin/main', repoRoot)
495
+ runGit(`push origin ${branch} --force-with-lease`, repoRoot)
496
+ rebaseResult = { rebased: true, commitsBehind: behind, message: `Auto-rebased ${behind} commit(s) from main — branch is now up to date` }
497
+ } else {
498
+ rebaseResult = { rebased: false, message: 'Branch is already up to date with main' }
499
+ }
500
+ } catch (rebaseErr) {
501
+ rebaseResult = { rebased: false, conflict: true, message: 'Auto-rebase failed — merge conflict detected. Run: git rebase origin/main and resolve conflicts manually.' }
502
+ }
503
+
504
+ gitResult = { switched: true, branch, rebase: rebaseResult }
505
+ } catch (e) {
506
+ gitResult = { switched: false, error: e.message.split('\n')[0], manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}`, 'git rebase origin/main'] }
507
+ }
508
+ }
509
+
510
+ // ── Clear park note + server-side comment & notification ──────────────
511
+ await api.patch(`/api/tasks/${taskId}/unpark`, {})
512
+
513
+ // ── Restore cursor rules file ──────────────────────────────────────────
424
514
  let cursorRulesFile = null
425
515
  if (task?.cursorRules?.trim()) {
426
516
  cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
427
517
  }
428
- return text({ ...(res?.data || {}), cursorRulesFile })
518
+
519
+ return text({
520
+ unparked: true,
521
+ task: { key: task?.key, title: task?.title },
522
+ git: gitResult || { switched: false, reason: 'No branch linked or repo not found' },
523
+ cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
524
+ commentPosted: true,
525
+ previousDevNotified: true,
526
+ parkNote: task?.parkNote || null,
527
+ message: gitResult?.switched
528
+ ? `You are now on branch "${branch}". Cursor rules restored. Start coding from where ${task?.parkNote?.parkedBy ? 'the previous developer' : 'you'} left off.`
529
+ : `Branch switch failed — see git.manualSteps. Cursor rules restored.`,
530
+ })
429
531
  }
430
532
  )
431
533
  }
@@ -927,10 +1029,18 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
927
1029
  if (!taskRes?.success) return errorText('Task not found')
928
1030
  const task = taskRes.data.task
929
1031
 
930
- const suggestedBranch = `feature/${task.key?.toLowerCase()}-${task.title
931
- .toLowerCase()
932
- .replace(/[^a-z0-9]+/g, '-')
933
- .slice(0, 40)}`
1032
+ // Include developer name in branch so it's clear who created it
1033
+ let devSlug = ''
1034
+ try {
1035
+ const meRes = await api.get('/api/auth/me')
1036
+ const devName = meRes?.data?.user?.name || meRes?.data?.name || ''
1037
+ devSlug = devName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 15)
1038
+ } catch { /* non-fatal */ }
1039
+
1040
+ const titleSlug = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 35)
1041
+ const suggestedBranch = devSlug
1042
+ ? `${devSlug}/feature/${task.key?.toLowerCase()}-${titleSlug}`
1043
+ : `feature/${task.key?.toLowerCase()}-${titleSlug}`
934
1044
 
935
1045
  const hasReadme = typeof task.readmeMarkdown === 'string' && task.readmeMarkdown.trim().length > 0
936
1046
  const hasCursorRules = typeof task.cursorRules === 'string' && task.cursorRules.trim().length > 0
@@ -1729,7 +1839,18 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
1729
1839
  .replace(/[^a-z0-9]+/g, '-')
1730
1840
  .replace(/^-|-$/g, '')
1731
1841
  .slice(0, 35)
1732
- const branchName = `${prefix}/${task.key.toLowerCase()}-${slug}`
1842
+
1843
+ // Include developer name so it's clear who created the branch
1844
+ let devSlug = ''
1845
+ try {
1846
+ const meRes = await api.get('/api/auth/me')
1847
+ const devName = meRes?.data?.user?.name || meRes?.data?.name || ''
1848
+ devSlug = devName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 15)
1849
+ } catch { /* non-fatal */ }
1850
+
1851
+ const branchName = devSlug
1852
+ ? `${devSlug}/${prefix}/${task.key.toLowerCase()}-${slug}`
1853
+ : `${prefix}/${task.key.toLowerCase()}-${slug}`
1733
1854
 
1734
1855
  // Auto-detect local git state
1735
1856
  let gitState = null
@@ -2351,6 +2472,32 @@ branchAction values (only needed when current branch ≠ task branch):
2351
2472
  ...unsafeUntracked.map(f => `⚠️ SKIP: ${f} ← do not commit this`),
2352
2473
  ]
2353
2474
 
2475
+ // ── Block if task is parked — prevent Dev A pushing after handoff ────────
2476
+ if (task) {
2477
+ const isParked = !!(task.parkNote?.parkedAt)
2478
+ const currentOwner = task.parkNote?.currentOwner
2479
+ const parkedBy = task.parkNote?.parkedBy
2480
+ let meId = ''
2481
+ try { const me = await api.get('/api/auth/me'); meId = me?.data?.user?._id || me?.data?._id || '' } catch { /* non-fatal */ }
2482
+
2483
+ if (isParked && parkedBy && parkedBy.toString() === meId) {
2484
+ return text({
2485
+ blocked: true,
2486
+ reason: `You parked "${task.title}". Another developer may have picked it up.`,
2487
+ advice: 'Check the task on the board before pushing. If you still own it, call unpark_task first, then commit.',
2488
+ message: 'Commit blocked — task is parked.',
2489
+ })
2490
+ }
2491
+ if (currentOwner && currentOwner.toString() !== meId && meId) {
2492
+ return text({
2493
+ blocked: true,
2494
+ reason: `"${task.title}" is currently owned by another developer who unparked it.`,
2495
+ advice: 'Do not push to this branch. Coordinate with the current owner first.',
2496
+ message: 'Commit blocked — another developer owns this task.',
2497
+ })
2498
+ }
2499
+ }
2500
+
2354
2501
  // ── PREVIEW (confirmed=false) ─────────────────────────────────────────────
2355
2502
  if (!confirmed) {
2356
2503
  // Case 1: branch mismatch — must resolve before anything else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.6.14",
3
+ "version": "1.6.19",
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",