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.
- package/index.js +306 -31
- 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
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
373
|
+
message: 'Review the preview above, then call park_task again with confirmed=true to execute.',
|
|
328
374
|
})
|
|
329
375
|
}
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
471
|
+
message: 'Read the handoff context above. Call unpark_task again with confirmed=true to execute all steps automatically.',
|
|
354
472
|
})
|
|
355
473
|
}
|
|
356
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2041
|
-
message:
|
|
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