internaltool-mcp 1.6.10 → 1.6.14

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 +191 -18
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -302,10 +302,11 @@ 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 save your current work context so another developer can safely pick it up.
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.
309
310
 
310
311
  Set confirmed=false first to preview, then confirmed=true to actually save.`,
311
312
  {
@@ -314,46 +315,117 @@ Set confirmed=false first to preview, then confirmed=true to actually save.`,
314
315
  remaining: z.string().optional().describe('Specific next steps to complete this task'),
315
316
  blockers: z.string().optional().describe('Anything blocking progress'),
316
317
  confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the preview'),
318
+ repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
317
319
  },
318
- async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false }) => {
320
+ async ({ taskId, summary = '', remaining = '', blockers = '', confirmed = false, repoPath }) => {
321
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
322
+ const task = taskRes?.data?.task
323
+
324
+ // ── Safety check: block if there are uncommitted or unpushed changes ──
325
+ const cwd = repoPath || process.cwd()
326
+ const repoRoot = findRepoRoot(cwd)
327
+ let gitBlockers = []
328
+ if (repoRoot) {
329
+ try {
330
+ 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
+ }
335
+ } catch { /* non-fatal */ }
336
+ try {
337
+ const currentBranch = runGit('branch --show-current', repoRoot).trim()
338
+ 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
+ }
344
+ }
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
+ })
355
+ }
356
+
319
357
  if (!confirmed) {
320
358
  return text({
321
359
  preview: {
322
360
  action: 'park_task',
323
361
  taskId,
362
+ task: task ? { key: task.key, title: task.title, branch: task.github?.headBranch || null } : null,
324
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,
325
368
  },
326
369
  requiresConfirmation: true,
327
- message: 'Review the park note above. Call park_task again with confirmed=true to save it.',
370
+ message: 'All changes are pushed. Review the park note above then call park_task again with confirmed=true.',
328
371
  })
329
372
  }
330
- return call(() => api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers }))
373
+
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
376
+ let cursorRulesCleared = null
377
+ if (task?.cursorRules?.trim()) {
378
+ cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
379
+ }
380
+ 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,
386
+ })
331
387
  }
332
388
  )
333
389
 
334
390
  server.tool(
335
391
  'unpark_task',
336
- 'Resume a parked task. Set confirmed=false first to preview, then confirmed=true to execute.',
392
+ 'Resume a parked task shows exactly what the previous developer left behind and what to do next.',
337
393
  {
338
394
  taskId: z.string().describe("Task's MongoDB ObjectId"),
339
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.'),
340
397
  },
341
- async ({ taskId, confirmed = false }) => {
398
+ async ({ taskId, confirmed = false, repoPath }) => {
399
+ const taskRes = await api.get(`/api/tasks/${taskId}`)
400
+ const task = taskRes?.data?.task
401
+ const branch = task?.github?.headBranch || null
342
402
  if (!confirmed) {
343
- const taskRes = await api.get(`/api/tasks/${taskId}`)
344
- const task = taskRes?.data?.task
345
403
  return text({
346
404
  preview: {
347
405
  action: 'unpark_task',
348
406
  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',
407
+ task: task ? { key: task.key, title: task.title, column: task.column } : null,
408
+ parkNote: task?.parkNote || null,
409
+ 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',
351
417
  },
352
418
  requiresConfirmation: true,
353
- message: 'Call unpark_task again with confirmed=true to resume this task.',
419
+ message: 'Read the park note and handoff steps above, then call unpark_task again with confirmed=true to take ownership.',
354
420
  })
355
421
  }
356
- return call(() => api.patch(`/api/tasks/${taskId}/unpark`, {}))
422
+ const res = await api.patch(`/api/tasks/${taskId}/unpark`, {})
423
+ // Restore cursor rules file now that this task is active again
424
+ let cursorRulesFile = null
425
+ if (task?.cursorRules?.trim()) {
426
+ cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
427
+ }
428
+ return text({ ...(res?.data || {}), cursorRulesFile })
357
429
  }
358
430
  )
359
431
  }
@@ -904,6 +976,39 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
904
976
  ]
905
977
  }
906
978
 
979
+ // ── Simultaneous work lock ──────────────────────────────────────────────────
980
+ // If the task is already in_progress with a branch, warn before proceeding.
981
+ // This prevents two developers from unknowingly working on the same task.
982
+ const alreadyActive = task.column === 'in_progress' && !!task.github?.headBranch
983
+ const activeAssignees = (task.assignees || []).map(a => a.name || a.email || a.toString())
984
+
985
+ if (!confirmed && alreadyActive) {
986
+ return text({
987
+ warning: {
988
+ type: 'simultaneous_work_detected',
989
+ message: `⚠️ This task is already in progress on branch "${task.github.headBranch}".`,
990
+ assignees: activeAssignees.length ? activeAssignees : ['(unassigned)'],
991
+ branch: task.github.headBranch,
992
+ parkNote: task.parkNote || null,
993
+ advice: activeAssignees.length
994
+ ? `Check with ${activeAssignees.join(', ')} before taking over. If they have parked this task their changes are pushed — checkout their branch instead of starting fresh.`
995
+ : `Someone may have been working on this. Check the branch before starting fresh.`,
996
+ checkoutSteps: [
997
+ 'git fetch origin',
998
+ `git checkout ${task.github.headBranch}`,
999
+ `git pull origin ${task.github.headBranch}`,
1000
+ ],
1001
+ },
1002
+ brief: {
1003
+ key: task.key,
1004
+ title: task.title,
1005
+ priority: task.priority,
1006
+ },
1007
+ requiresConfirmation: true,
1008
+ message: `Task is already in progress. Read the warning above. If you still want to take over, call kickoff_task again with confirmed=true.`,
1009
+ })
1010
+ }
1011
+
907
1012
  if (!confirmed) {
908
1013
  return text({
909
1014
  CURSOR_RULES: hasCursorRules
@@ -1308,6 +1413,32 @@ function parseGitStatus(porcelain) {
1308
1413
  return { staged, unstaged, untracked, modified, localState }
1309
1414
  }
1310
1415
 
1416
+ /**
1417
+ * Returns how many commits the current branch is behind/ahead of its base branch,
1418
+ * and whether there are uncommitted changes. Fetches from origin first.
1419
+ * Returns null on any git error (e.g. no remote configured).
1420
+ */
1421
+ function getBranchSyncStatus(cwd, baseBranch = 'main') {
1422
+ try {
1423
+ runGit('fetch origin', cwd)
1424
+ } catch { /* no remote or no network — continue with local data */ }
1425
+ try {
1426
+ const behind = parseInt(runGit(`rev-list HEAD..origin/${baseBranch} --count`, cwd).trim(), 10) || 0
1427
+ const unpushed = parseInt(runGit(`rev-list origin/${baseBranch}..HEAD --count`, cwd).trim(), 10) || 0
1428
+ // Check if the remote tracking branch for the feature branch exists and has unpushed commits
1429
+ let unpushedOnBranch = 0
1430
+ try {
1431
+ const currentBranch = runGit('branch --show-current', cwd).trim()
1432
+ unpushedOnBranch = parseInt(runGit(`rev-list origin/${currentBranch}..HEAD --count`, cwd).trim(), 10) || 0
1433
+ } catch { /* remote branch may not exist yet — all commits are unpushed */ }
1434
+ const porcelain = runGit('status --porcelain=v1', cwd)
1435
+ const { localState } = parseGitStatus(porcelain)
1436
+ return { behind, unpushed, unpushedOnBranch, localState, baseBranch }
1437
+ } catch {
1438
+ return null
1439
+ }
1440
+ }
1441
+
1311
1442
  // ── Git Workflow Tools ─────────────────────────────────────────────────────────
1312
1443
  // All write operations require confirmed=true after showing a preview.
1313
1444
  // This gives developers full visibility before anything is executed.
@@ -2028,8 +2159,33 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2028
2159
  ].filter(v => v !== null).join('\n')
2029
2160
 
2030
2161
  if (!confirmed) {
2162
+ // ── Pre-flight: check branch sync status before allowing PR ──
2163
+ const cwd = repoPath || process.cwd()
2164
+ const repoRoot = findRepoRoot(cwd)
2165
+ const sync = repoRoot ? getBranchSyncStatus(repoRoot) : null
2166
+
2167
+ const preflight = { status: 'ok', warnings: [], blockers: [] }
2168
+ if (sync) {
2169
+ if (sync.behind > 0) {
2170
+ preflight.blockers.push(
2171
+ `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}`
2172
+ )
2173
+ }
2174
+ if (sync.unpushedOnBranch > 0) {
2175
+ preflight.blockers.push(
2176
+ `You have ${sync.unpushedOnBranch} local commit(s) not pushed to remote. Push first: git push origin ${headBranch}`
2177
+ )
2178
+ }
2179
+ if (sync.localState === 'modified') {
2180
+ preflight.warnings.push('You have uncommitted local changes. Commit or stash them before the PR is merged.')
2181
+ }
2182
+ if (preflight.blockers.length > 0) preflight.status = 'blocked'
2183
+ else if (preflight.warnings.length > 0) preflight.status = 'warning'
2184
+ }
2185
+
2031
2186
  return text({
2032
- preview: {
2187
+ preflight,
2188
+ preview: preflight.status === 'blocked' ? null : {
2033
2189
  action: 'raise_pr',
2034
2190
  prTitle,
2035
2191
  prBody: bodyParts,
@@ -2037,8 +2193,10 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
2037
2193
  draft,
2038
2194
  task: { key: task.key, title: task.title },
2039
2195
  },
2040
- requiresConfirmation: true,
2041
- message: `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2196
+ requiresConfirmation: preflight.status !== 'blocked',
2197
+ message: preflight.status === 'blocked'
2198
+ ? `⛔ Cannot raise PR — fix the blockers above first.`
2199
+ : `Will open a${draft ? ' draft' : ''} PR titled "${prTitle}". Call raise_pr again with confirmed=true to create it.`,
2042
2200
  })
2043
2201
  }
2044
2202
 
@@ -2246,6 +2404,20 @@ branchAction values (only needed when current branch ≠ task branch):
2246
2404
 
2247
2405
  // Case 2: on correct branch — standard preview
2248
2406
  const pushCmd = `git push origin ${currentBranch}`
2407
+
2408
+ // Check if branch is behind base — warn developer to rebase before more commits pile up
2409
+ let divergenceWarning = null
2410
+ try {
2411
+ runGit('fetch origin', cwd)
2412
+ const repoRoot = findRepoRoot(cwd)
2413
+ if (repoRoot) {
2414
+ const behind = parseInt(runGit(`rev-list HEAD..origin/main --count`, repoRoot).trim(), 10) || 0
2415
+ if (behind > 0) {
2416
+ 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.`
2417
+ }
2418
+ }
2419
+ } catch { /* non-fatal */ }
2420
+
2249
2421
  return text({
2250
2422
  preview: {
2251
2423
  suggestedMessage: commitMsg,
@@ -2256,6 +2428,7 @@ branchAction values (only needed when current branch ≠ task branch):
2256
2428
  onCorrectBranch: !branchMismatch,
2257
2429
  changedFiles: changedFilesList,
2258
2430
  },
2431
+ divergenceWarning,
2259
2432
  unsafeUntrackedWarning: unsafeUntracked.length
2260
2433
  ? `These paths should NOT be committed — add them to .gitignore first: ${unsafeUntracked.join(', ')}`
2261
2434
  : 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.14",
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",