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.
- package/index.js +191 -18
- 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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
370
|
+
message: 'All changes are pushed. Review the park note above then call park_task again with confirmed=true.',
|
|
328
371
|
})
|
|
329
372
|
}
|
|
330
|
-
|
|
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
|
|
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,
|
|
350
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2041
|
-
message:
|
|
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