internaltool-mcp 1.6.17 → 1.6.22
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 +1154 -68
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -346,25 +346,16 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
|
|
|
346
346
|
} catch { /* remote branch may not exist */ }
|
|
347
347
|
}
|
|
348
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
|
-
|
|
359
349
|
if (!confirmed) {
|
|
360
350
|
return text({
|
|
361
351
|
preview: {
|
|
362
352
|
task: task ? { key: task.key, title: task.title, branch: task.github?.headBranch || null } : null,
|
|
363
353
|
parkNote: { summary: summary || '(empty)', remaining: remaining || '(empty)', blockers: blockers || '(empty)' },
|
|
364
354
|
willAutomate: [
|
|
365
|
-
|
|
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',
|
|
366
357
|
'Save park note to task',
|
|
367
|
-
task?.cursorRules?.trim() ?
|
|
358
|
+
task?.cursorRules?.trim() ? `Delete cursor rules file (.cursor/rules/${task.key?.toLowerCase()}.mdc)` : null,
|
|
368
359
|
'Post handoff comment on the task',
|
|
369
360
|
'Notify project members',
|
|
370
361
|
].filter(Boolean),
|
|
@@ -374,6 +365,18 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
|
|
|
374
365
|
})
|
|
375
366
|
}
|
|
376
367
|
|
|
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
|
+
|
|
377
380
|
// ── Auto-push unpushed commits ─────────────────────────────────────────
|
|
378
381
|
let pushed = false
|
|
379
382
|
if (repoRoot && currentBranch && unpushedCount > 0) {
|
|
@@ -394,10 +397,16 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
|
|
|
394
397
|
cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
|
|
395
398
|
}
|
|
396
399
|
|
|
400
|
+
// ── #9 Capture last commit for handoff metadata ───────────────────────
|
|
401
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
402
|
+
|
|
397
403
|
return text({
|
|
398
404
|
parked: true,
|
|
399
405
|
task: { key: task?.key, title: task?.title, branch: task?.github?.headBranch || null },
|
|
400
406
|
autoPushed: pushed ? `${unpushedCount} commit(s) pushed to origin/${currentBranch}` : 'No push needed',
|
|
407
|
+
lastCommit: lastCommit
|
|
408
|
+
? { sha: lastCommit.sha, subject: lastCommit.subject, author: lastCommit.author, date: lastCommit.date }
|
|
409
|
+
: null,
|
|
401
410
|
cursorRulesCleared: cursorRulesCleared ? `Deleted ${cursorRulesCleared}` : null,
|
|
402
411
|
commentPosted: true,
|
|
403
412
|
teamNotified: true,
|
|
@@ -424,8 +433,9 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
424
433
|
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
425
434
|
confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the park note'),
|
|
426
435
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
|
|
436
|
+
autoStash: z.boolean().optional().default(false).describe('Auto-stash dirty working tree before switching branches instead of blocking. Stash is labelled with the task key for easy recovery.'),
|
|
427
437
|
},
|
|
428
|
-
async ({ taskId, confirmed = false, repoPath }) => {
|
|
438
|
+
async ({ taskId, confirmed = false, repoPath, autoStash = false }) => {
|
|
429
439
|
const taskRes = await api.get(`/api/tasks/${taskId}`)
|
|
430
440
|
const task = taskRes?.data?.task
|
|
431
441
|
const branch = task?.github?.headBranch || null
|
|
@@ -477,14 +487,59 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
477
487
|
const repoRoot = findRepoRoot(cwd)
|
|
478
488
|
let gitResult = null
|
|
479
489
|
|
|
490
|
+
// ── #1/#2 Preflight: repo readiness guard + autoStash ─────────────────
|
|
491
|
+
if (repoRoot && branch) {
|
|
492
|
+
try {
|
|
493
|
+
const porcelain = runGit('status --porcelain=v1', repoRoot)
|
|
494
|
+
const { localState, modified } = parseGitStatus(porcelain)
|
|
495
|
+
const currentBranchNow = runGit('branch --show-current', repoRoot).trim()
|
|
496
|
+
if (localState === 'modified') {
|
|
497
|
+
if (autoStash) {
|
|
498
|
+
// Auto-stash with a clear label so it can be recovered later
|
|
499
|
+
const stashLabel = `auto-stash(${task?.key?.toLowerCase() || 'unpark'}): changes on ${currentBranchNow} before switching to ${branch}`
|
|
500
|
+
runGit(`stash push -m "${stashLabel}"`, repoRoot)
|
|
501
|
+
} else {
|
|
502
|
+
return text({
|
|
503
|
+
blocked: true,
|
|
504
|
+
reason: `Working tree has ${modified.length} modified file(s) on "${currentBranchNow}" — cannot switch branches safely.`,
|
|
505
|
+
modifiedFiles: modified,
|
|
506
|
+
choices: {
|
|
507
|
+
stash: `git stash push -m "wip: before unpark ${task?.key || ''} on ${currentBranchNow}"`,
|
|
508
|
+
commit: `git add . && git commit -m "wip(${task?.key?.toLowerCase() || 'task'}): before unpark"`,
|
|
509
|
+
autoStash: `Call unpark_task again with autoStash=true — MCP will stash automatically with a labelled entry`,
|
|
510
|
+
cancel: 'Review and handle your changes, then retry',
|
|
511
|
+
},
|
|
512
|
+
message: 'Handle the working tree before switching branches. Tip: use autoStash=true to let MCP do it automatically.',
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} catch { /* non-fatal — proceed with checkout anyway */ }
|
|
517
|
+
}
|
|
518
|
+
|
|
480
519
|
if (repoRoot && branch) {
|
|
481
520
|
try {
|
|
482
521
|
runGit('fetch origin', repoRoot)
|
|
483
522
|
runGit(`checkout ${branch}`, repoRoot)
|
|
484
523
|
runGit(`pull origin ${branch}`, repoRoot)
|
|
485
|
-
|
|
524
|
+
|
|
525
|
+
// Auto-rebase from main so Dev B starts on a clean, up-to-date branch
|
|
526
|
+
let rebaseResult = null
|
|
527
|
+
try {
|
|
528
|
+
const behind = parseInt(runGit(`rev-list HEAD..origin/main --count`, repoRoot).trim(), 10) || 0
|
|
529
|
+
if (behind > 0) {
|
|
530
|
+
runGit('rebase origin/main', repoRoot)
|
|
531
|
+
runGit(`push origin ${branch} --force-with-lease`, repoRoot)
|
|
532
|
+
rebaseResult = { rebased: true, commitsBehind: behind, message: `Auto-rebased ${behind} commit(s) from main — branch is now up to date` }
|
|
533
|
+
} else {
|
|
534
|
+
rebaseResult = { rebased: false, message: 'Branch is already up to date with main' }
|
|
535
|
+
}
|
|
536
|
+
} catch (rebaseErr) {
|
|
537
|
+
rebaseResult = { rebased: false, conflict: true, message: 'Auto-rebase failed — merge conflict detected. Run: git rebase origin/main and resolve conflicts manually.' }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
gitResult = { switched: true, branch, rebase: rebaseResult }
|
|
486
541
|
} catch (e) {
|
|
487
|
-
gitResult = { switched: false, error: e.message.split('\n')[0], manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}
|
|
542
|
+
gitResult = { switched: false, error: e.message.split('\n')[0], manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}`, 'git rebase origin/main'] }
|
|
488
543
|
}
|
|
489
544
|
}
|
|
490
545
|
|
|
@@ -497,10 +552,15 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
497
552
|
cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
|
|
498
553
|
}
|
|
499
554
|
|
|
555
|
+
// ── #9 Last commit metadata for handoff context ───────────────────────
|
|
556
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
557
|
+
|
|
500
558
|
return text({
|
|
501
559
|
unparked: true,
|
|
502
560
|
task: { key: task?.key, title: task?.title },
|
|
503
561
|
git: gitResult || { switched: false, reason: 'No branch linked or repo not found' },
|
|
562
|
+
autoStashed: autoStash ? 'Changes were auto-stashed before branch switch. Run: git stash list to see them.' : null,
|
|
563
|
+
lastCommit: lastCommit || null,
|
|
504
564
|
cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
|
|
505
565
|
commentPosted: true,
|
|
506
566
|
previousDevNotified: true,
|
|
@@ -511,6 +571,482 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
511
571
|
})
|
|
512
572
|
}
|
|
513
573
|
)
|
|
574
|
+
|
|
575
|
+
// ── review_pr ────────────────────────────────────────────────────────────────
|
|
576
|
+
server.tool(
|
|
577
|
+
'review_pr',
|
|
578
|
+
`Fetch everything needed to do a thorough PR review: PR details, full code diff, CI status, and the task's implementation plan.
|
|
579
|
+
|
|
580
|
+
Returns the diff file-by-file (filename, additions, deletions, patch hunks), the task's README spec, acceptance criteria, and CI check results so you can verify code against the plan.
|
|
581
|
+
|
|
582
|
+
Call this FIRST before post_pr_review. Use it when the developer asks "review PR", "check the PR for TASK-X", or "is this PR ready to merge".`,
|
|
583
|
+
{
|
|
584
|
+
taskId: z.string().describe("Task's MongoDB ObjectId (used to find the project + PR number)"),
|
|
585
|
+
},
|
|
586
|
+
async ({ taskId }) => {
|
|
587
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
588
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
589
|
+
const task = taskRes.data.task
|
|
590
|
+
|
|
591
|
+
const prNumber = task.github?.prNumber
|
|
592
|
+
if (!prNumber) return errorText('No PR linked to this task. The developer must raise_pr first.')
|
|
593
|
+
|
|
594
|
+
const projectId = task.project?._id || task.project
|
|
595
|
+
const res = await apiWithRetry(() => api.get(`/api/projects/${projectId}/github/pull-requests/${prNumber}`))
|
|
596
|
+
if (!res?.success) return errorText(res?.message || 'Could not fetch PR from GitHub')
|
|
597
|
+
|
|
598
|
+
const { pr, files, checks, task: linkedTask } = res.data
|
|
599
|
+
|
|
600
|
+
// Build a reviewer checklist from the implementation plan
|
|
601
|
+
const hasReadme = linkedTask?.readmeMarkdown?.trim()
|
|
602
|
+
const checklist = [
|
|
603
|
+
'[ ] Code matches the implementation plan (README)',
|
|
604
|
+
'[ ] All subtasks/acceptance criteria covered',
|
|
605
|
+
'[ ] No obvious bugs or edge cases missed',
|
|
606
|
+
'[ ] No hardcoded secrets or credentials',
|
|
607
|
+
'[ ] Error handling is in place where needed',
|
|
608
|
+
'[ ] No console.log / debug code left in',
|
|
609
|
+
checks?.anyFailed ? '[ ] ⚠️ CI checks are failing — investigate before approving' : '[ ] CI checks passing ✅',
|
|
610
|
+
pr.changesRequested > 0 ? `[ ] ⚠️ ${pr.changesRequested} reviewer(s) already requested changes` : null,
|
|
611
|
+
].filter(Boolean)
|
|
612
|
+
|
|
613
|
+
return text({
|
|
614
|
+
pr: {
|
|
615
|
+
number: pr.number,
|
|
616
|
+
title: pr.title,
|
|
617
|
+
author: pr.author,
|
|
618
|
+
headBranch: pr.headBranch,
|
|
619
|
+
baseBranch: pr.baseBranch,
|
|
620
|
+
state: pr.state,
|
|
621
|
+
approvals: pr.approvals,
|
|
622
|
+
changesRequested: pr.changesRequested,
|
|
623
|
+
mergeable: pr.mergeable,
|
|
624
|
+
mergeableState: pr.mergeableState,
|
|
625
|
+
url: pr.htmlUrl,
|
|
626
|
+
},
|
|
627
|
+
ci: checks || { runs: [], allPassed: null, note: 'Could not fetch CI checks' },
|
|
628
|
+
diff: {
|
|
629
|
+
totalFiles: files.length,
|
|
630
|
+
totalAdditions: files.reduce((s, f) => s + f.additions, 0),
|
|
631
|
+
totalDeletions: files.reduce((s, f) => s + f.deletions, 0),
|
|
632
|
+
files: files.map(f => ({
|
|
633
|
+
filename: f.filename,
|
|
634
|
+
status: f.status,
|
|
635
|
+
additions: f.additions,
|
|
636
|
+
deletions: f.deletions,
|
|
637
|
+
patch: f.patch || '(binary or no diff)',
|
|
638
|
+
})),
|
|
639
|
+
},
|
|
640
|
+
spec: {
|
|
641
|
+
taskKey: linkedTask?.key,
|
|
642
|
+
taskTitle: linkedTask?.title,
|
|
643
|
+
implementationPlan: hasReadme ? linkedTask.readmeMarkdown : '⚠️ No implementation plan — review code against description only.',
|
|
644
|
+
description: linkedTask?.description || null,
|
|
645
|
+
},
|
|
646
|
+
reviewChecklist: checklist,
|
|
647
|
+
nextStep: pr.approvals > 0 && !pr.changesRequested
|
|
648
|
+
? `PR has ${pr.approvals} approval(s) and no changes requested. Call merge_pr with taskId="${taskId}" when ready.`
|
|
649
|
+
: `Review the diff above against the spec, then call post_pr_review with taskId="${taskId}" to post your verdict.`,
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
// ── post_pr_review ────────────────────────────────────────────────────────────
|
|
655
|
+
server.tool(
|
|
656
|
+
'post_pr_review',
|
|
657
|
+
`Post a GitHub PR review after analyzing the code diff.
|
|
658
|
+
|
|
659
|
+
Supports three actions:
|
|
660
|
+
- APPROVE — Approves the PR. Notifies the developer they can merge.
|
|
661
|
+
- REQUEST_CHANGES — Blocks merge. Developer gets notified. Task board updated.
|
|
662
|
+
- COMMENT — Leaves a comment without approving or blocking.
|
|
663
|
+
|
|
664
|
+
Always call review_pr first to get the diff and spec context. Then call this with your verdict and reasoning.
|
|
665
|
+
|
|
666
|
+
IMPORTANT: This posts a real GitHub review and updates the InternalTool board. The human must confirm before this runs — always show the review body to the user and ask for approval before calling with confirmed=true.`,
|
|
667
|
+
{
|
|
668
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
669
|
+
event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe('Review action'),
|
|
670
|
+
body: z.string().describe('Review summary — be specific: what was good, what needs fixing, which files/lines'),
|
|
671
|
+
confirmed: z.boolean().optional().default(false).describe('Set true only after the human has read and approved the review text'),
|
|
672
|
+
},
|
|
673
|
+
async ({ taskId, event, body: reviewBody, confirmed = false }) => {
|
|
674
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
675
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
676
|
+
const task = taskRes.data.task
|
|
677
|
+
|
|
678
|
+
const prNumber = task.github?.prNumber
|
|
679
|
+
const projectId = task.project?._id || task.project
|
|
680
|
+
if (!prNumber) return errorText('No PR linked to this task.')
|
|
681
|
+
|
|
682
|
+
// Always preview first — human must confirm
|
|
683
|
+
if (!confirmed) {
|
|
684
|
+
return text({
|
|
685
|
+
preview: {
|
|
686
|
+
action: event,
|
|
687
|
+
prNumber,
|
|
688
|
+
taskKey: task.key,
|
|
689
|
+
taskTitle: task.title,
|
|
690
|
+
reviewBody,
|
|
691
|
+
},
|
|
692
|
+
warning: event === 'APPROVE'
|
|
693
|
+
? '✅ This will APPROVE the PR on GitHub and notify the developer they can merge.'
|
|
694
|
+
: event === 'REQUEST_CHANGES'
|
|
695
|
+
? '⚠️ This will REQUEST CHANGES — the developer will be blocked from merging until they fix the issues and you re-approve.'
|
|
696
|
+
: 'ℹ️ This will post a comment on the PR without approving or blocking.',
|
|
697
|
+
requiresConfirmation: true,
|
|
698
|
+
message: `Show the review body above to the user and ask them to confirm. Then call post_pr_review again with confirmed=true.`,
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const res = await api.post(
|
|
703
|
+
`/api/projects/${projectId}/github/pull-requests/${prNumber}/reviews`,
|
|
704
|
+
{ event, body: reviewBody }
|
|
705
|
+
)
|
|
706
|
+
if (!res?.success) return errorText(res?.message || 'Could not post review')
|
|
707
|
+
|
|
708
|
+
return text({
|
|
709
|
+
posted: true,
|
|
710
|
+
event,
|
|
711
|
+
prNumber,
|
|
712
|
+
taskKey: task.key,
|
|
713
|
+
message: event === 'APPROVE'
|
|
714
|
+
? `✅ PR #${prNumber} approved. Developer has been notified and can now merge.`
|
|
715
|
+
: event === 'REQUEST_CHANGES'
|
|
716
|
+
? `⚠️ Changes requested on PR #${prNumber}. Developer has been notified. Task board updated.`
|
|
717
|
+
: `💬 Comment posted on PR #${prNumber}.`,
|
|
718
|
+
nextStep: event === 'APPROVE'
|
|
719
|
+
? `Call merge_pr with taskId="${taskId}" to merge, or wait for the developer to merge.`
|
|
720
|
+
: event === 'REQUEST_CHANGES'
|
|
721
|
+
? `Wait for the developer to push fixes. They'll call fix_pr_feedback, then re-push. You'll get notified.`
|
|
722
|
+
: null,
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
// ── merge_pr ─────────────────────────────────────────────────────────────────
|
|
728
|
+
server.tool(
|
|
729
|
+
'merge_pr',
|
|
730
|
+
`Merge a pull request after human approval. Runs safety checks before executing.
|
|
731
|
+
|
|
732
|
+
Safety checks (pre-merge):
|
|
733
|
+
- PR must be open and not already merged
|
|
734
|
+
- No pending change requests
|
|
735
|
+
- CI checks must be passing (pass skipChecks=true to override)
|
|
736
|
+
|
|
737
|
+
Human approval gate:
|
|
738
|
+
- confirmed=false (default): shows safety check results and asks for confirmation
|
|
739
|
+
- confirmed=true: executes the merge
|
|
740
|
+
|
|
741
|
+
The task moves to Done automatically via the existing GitHub webhook. A final comment is posted on the task.
|
|
742
|
+
|
|
743
|
+
Use this when the reviewer says "merge it", "looks good, ship it", or "merge the PR for TASK-X".`,
|
|
744
|
+
{
|
|
745
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
746
|
+
confirmed: z.boolean().optional().default(false).describe('Set true only after the human has reviewed the safety checks and approved the merge'),
|
|
747
|
+
mergeMethod: z.enum(['squash', 'merge', 'rebase']).optional().default('squash').describe('Merge strategy (default: squash)'),
|
|
748
|
+
commitTitle: z.string().optional().describe('Override the merge commit title. Defaults to PR title (#number).'),
|
|
749
|
+
skipChecks: z.boolean().optional().default(false).describe('Skip CI check gate — use only when checks are informational or flaky'),
|
|
750
|
+
},
|
|
751
|
+
async ({ taskId, confirmed = false, mergeMethod = 'squash', commitTitle, skipChecks = false }) => {
|
|
752
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
753
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
754
|
+
const task = taskRes.data.task
|
|
755
|
+
|
|
756
|
+
const prNumber = task.github?.prNumber
|
|
757
|
+
const projectId = task.project?._id || task.project
|
|
758
|
+
if (!prNumber) return errorText(`No PR linked to task ${task.key}. The developer must raise_pr first.`)
|
|
759
|
+
|
|
760
|
+
// Fetch current PR state + checks for the preview
|
|
761
|
+
const prContextRes = await api.get(`/api/projects/${projectId}/github/pull-requests/${prNumber}`)
|
|
762
|
+
if (!prContextRes?.success) return errorText('Could not fetch PR details from GitHub')
|
|
763
|
+
const { pr, checks } = prContextRes.data
|
|
764
|
+
|
|
765
|
+
if (pr.merged) {
|
|
766
|
+
return text({ alreadyMerged: true, message: `PR #${prNumber} is already merged. Task should be in Done.` })
|
|
767
|
+
}
|
|
768
|
+
if (pr.state !== 'open') {
|
|
769
|
+
return text({ blocked: true, reason: `PR #${prNumber} is ${pr.state} — cannot merge a closed PR.` })
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Build safety check summary
|
|
773
|
+
const safetyChecks = [
|
|
774
|
+
{ check: 'PR is open', pass: pr.state === 'open' },
|
|
775
|
+
{ check: 'Not already merged', pass: !pr.merged },
|
|
776
|
+
{ check: 'No change requests', pass: pr.changesRequested === 0,
|
|
777
|
+
note: pr.changesRequested > 0 ? `${pr.changesRequested} reviewer(s) requested changes` : null },
|
|
778
|
+
{ check: `CI checks (${checks?.total ?? 0} runs)`,
|
|
779
|
+
pass: skipChecks ? null : (checks?.allPassed ?? null),
|
|
780
|
+
note: skipChecks ? 'Skipped by request' : checks?.anyFailed ? 'Some checks failed' : checks?.allPassed ? 'All passing' : 'No checks found' },
|
|
781
|
+
{ check: 'Has at least one approval', pass: pr.approvals > 0,
|
|
782
|
+
note: pr.approvals === 0 ? 'No approvals yet — consider running post_pr_review first' : `${pr.approvals} approval(s)` },
|
|
783
|
+
]
|
|
784
|
+
|
|
785
|
+
const hardBlocks = safetyChecks.filter(c => c.pass === false && c.check !== `CI checks (${checks?.total ?? 0} runs)`)
|
|
786
|
+
const ciBlocked = !skipChecks && checks?.anyFailed
|
|
787
|
+
|
|
788
|
+
if (!confirmed) {
|
|
789
|
+
return text({
|
|
790
|
+
pr: {
|
|
791
|
+
number: pr.number,
|
|
792
|
+
title: pr.title,
|
|
793
|
+
author: pr.author,
|
|
794
|
+
headBranch: pr.headBranch,
|
|
795
|
+
approvals: pr.approvals,
|
|
796
|
+
url: pr.htmlUrl,
|
|
797
|
+
},
|
|
798
|
+
safetyChecks,
|
|
799
|
+
mergeStrategy: mergeMethod,
|
|
800
|
+
blocked: hardBlocks.length > 0 || ciBlocked,
|
|
801
|
+
blockers: [
|
|
802
|
+
...hardBlocks.map(c => c.note || c.check),
|
|
803
|
+
ciBlocked ? 'CI checks are failing (pass skipChecks=true to override)' : null,
|
|
804
|
+
].filter(Boolean),
|
|
805
|
+
requiresConfirmation: hardBlocks.length === 0 && !ciBlocked,
|
|
806
|
+
message: hardBlocks.length > 0 || ciBlocked
|
|
807
|
+
? `⚠️ Merge blocked. Resolve the issues above before merging.`
|
|
808
|
+
: `Show the safety checks above to the user and ask them to confirm the merge. Then call merge_pr again with confirmed=true.`,
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Hard-block even on confirmed=true if there are unresolved change requests
|
|
813
|
+
if (pr.changesRequested > 0) {
|
|
814
|
+
return text({
|
|
815
|
+
blocked: true,
|
|
816
|
+
reason: `Cannot merge: ${pr.changesRequested} reviewer(s) have requested changes. They must approve before merging.`,
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Execute merge
|
|
821
|
+
const mergeRes = await api.put(
|
|
822
|
+
`/api/projects/${projectId}/github/pull-requests/${prNumber}/merge`,
|
|
823
|
+
{ mergeMethod, commitTitle: commitTitle || undefined, skipChecks }
|
|
824
|
+
)
|
|
825
|
+
if (!mergeRes?.success) return errorText(mergeRes?.message || 'Merge failed')
|
|
826
|
+
|
|
827
|
+
if (mergeRes.data?.alreadyMerged) {
|
|
828
|
+
return text({ merged: true, message: 'PR was already merged.' })
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return text({
|
|
832
|
+
merged: true,
|
|
833
|
+
sha: mergeRes.data?.sha,
|
|
834
|
+
prNumber,
|
|
835
|
+
taskKey: task.key,
|
|
836
|
+
mergeMethod,
|
|
837
|
+
message: `✅ PR #${prNumber} merged via ${mergeMethod}. Task "${task.key}" will move to Done automatically.`,
|
|
838
|
+
nextStep: `The GitHub webhook will update the board. If the task doesn't move to Done within a minute, call update_task with column="done" manually.`,
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
// ── resume_task ───────────────────────────────────────────────────────────────
|
|
844
|
+
server.tool(
|
|
845
|
+
'resume_task',
|
|
846
|
+
`One-click safe resume for a task you previously worked on.
|
|
847
|
+
|
|
848
|
+
Combines: context fetch + dirty-tree check + optional auto-stash + checkout + pull + readiness summary.
|
|
849
|
+
|
|
850
|
+
Use this when the developer says "resume task", "get back to task X", "continue my work on", or
|
|
851
|
+
"switch back to TASK-KEY". Prefer this over manually running git commands.
|
|
852
|
+
|
|
853
|
+
Flow:
|
|
854
|
+
1. confirmed=false (default) → shows full context: park note, last commits, working-tree state, what will happen
|
|
855
|
+
2. confirmed=true → executes: stash (if needed + autoStash=true), checkout, pull, restore cursor rules, return readiness summary`,
|
|
856
|
+
{
|
|
857
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
858
|
+
confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the context to execute the resume'),
|
|
859
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo. Defaults to MCP process working directory.'),
|
|
860
|
+
autoStash: z.boolean().optional().default(false).describe('If working tree is dirty, auto-stash before switching. Labelled with task key.'),
|
|
861
|
+
},
|
|
862
|
+
async ({ taskId, confirmed = false, repoPath, autoStash = false }) => {
|
|
863
|
+
// ── Fetch task ────────────────────────────────────────────────────────
|
|
864
|
+
let taskRes
|
|
865
|
+
try { taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`)) }
|
|
866
|
+
catch (e) { return errorText(`Could not fetch task: ${e.message}`) }
|
|
867
|
+
if (!taskRes?.success) return errorText(taskRes?.message || 'Task not found')
|
|
868
|
+
const task = taskRes.data.task
|
|
869
|
+
const branch = task?.github?.headBranch || null
|
|
870
|
+
|
|
871
|
+
// ── Fetch recent comments ─────────────────────────────────────────────
|
|
872
|
+
let recentComments = []
|
|
873
|
+
try {
|
|
874
|
+
const commentsRes = await api.get(`/api/tasks/${taskId}/comments`)
|
|
875
|
+
recentComments = (commentsRes?.data?.comments || []).slice(-5).map(c => ({
|
|
876
|
+
author: c.author?.name || c.author?.email || 'unknown',
|
|
877
|
+
body: c.body?.slice(0, 300),
|
|
878
|
+
at: c.createdAt,
|
|
879
|
+
}))
|
|
880
|
+
} catch { /* non-fatal */ }
|
|
881
|
+
|
|
882
|
+
// ── Fetch recent commits on branch ────────────────────────────────────
|
|
883
|
+
let recentCommits = []
|
|
884
|
+
if (branch) {
|
|
885
|
+
try {
|
|
886
|
+
const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=5&branch=${branch}`)
|
|
887
|
+
recentCommits = (commitsRes?.data?.commits || []).map(c => ({
|
|
888
|
+
sha: c.sha?.slice(0, 7),
|
|
889
|
+
message: c.commit?.message?.split('\n')[0],
|
|
890
|
+
author: c.commit?.author?.name,
|
|
891
|
+
date: c.commit?.author?.date,
|
|
892
|
+
}))
|
|
893
|
+
} catch { /* non-fatal */ }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ── Inspect local repo state ──────────────────────────────────────────
|
|
897
|
+
const repoRoot = findRepoRoot(repoPath || process.cwd())
|
|
898
|
+
let localState = 'clean'
|
|
899
|
+
let modified = []
|
|
900
|
+
let currentBranch = null
|
|
901
|
+
let alreadyOnBranch = false
|
|
902
|
+
|
|
903
|
+
if (repoRoot) {
|
|
904
|
+
try {
|
|
905
|
+
const porcelain = runGit('status --porcelain=v1', repoRoot)
|
|
906
|
+
const parsed = parseGitStatus(porcelain)
|
|
907
|
+
localState = parsed.localState
|
|
908
|
+
modified = parsed.modified
|
|
909
|
+
currentBranch = runGit('branch --show-current', repoRoot).trim()
|
|
910
|
+
alreadyOnBranch = branch && currentBranch === branch
|
|
911
|
+
} catch { /* non-fatal */ }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ── Preview ───────────────────────────────────────────────────────────
|
|
915
|
+
if (!confirmed) {
|
|
916
|
+
const willStash = localState === 'modified' && !alreadyOnBranch
|
|
917
|
+
const stashBlocks = willStash && !autoStash
|
|
918
|
+
|
|
919
|
+
return text({
|
|
920
|
+
task: {
|
|
921
|
+
key: task.key,
|
|
922
|
+
title: task.title,
|
|
923
|
+
column: task.column,
|
|
924
|
+
priority: task.priority,
|
|
925
|
+
branch: branch || '(no branch)',
|
|
926
|
+
},
|
|
927
|
+
parkNote: task.parkNote || null,
|
|
928
|
+
recentCommits: recentCommits.length ? recentCommits : ['No remote commits found'],
|
|
929
|
+
recentComments,
|
|
930
|
+
localRepo: repoRoot ? {
|
|
931
|
+
root: repoRoot,
|
|
932
|
+
currentBranch,
|
|
933
|
+
alreadyOnBranch,
|
|
934
|
+
workingTree: localState,
|
|
935
|
+
modifiedFiles: modified.length ? modified : null,
|
|
936
|
+
} : { found: false, note: 'No git repo found at ' + (repoPath || process.cwd()) },
|
|
937
|
+
willDo: branch && !alreadyOnBranch ? [
|
|
938
|
+
willStash && autoStash ? `git stash push -m "auto-stash(${task.key?.toLowerCase()}): changes on ${currentBranch} before resuming"` : null,
|
|
939
|
+
`git fetch origin`,
|
|
940
|
+
`git checkout ${branch}`,
|
|
941
|
+
`git pull origin ${branch}`,
|
|
942
|
+
task?.cursorRules?.trim() ? `Restore .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
|
|
943
|
+
].filter(Boolean) : alreadyOnBranch ? [`git pull origin ${branch} (already on branch)`] : ['No branch to switch to'],
|
|
944
|
+
blocked: stashBlocks ? {
|
|
945
|
+
reason: `Working tree is dirty (${modified.length} file(s) modified) and you are not on the task branch.`,
|
|
946
|
+
choices: {
|
|
947
|
+
autoStash: `Call resume_task again with autoStash=true — MCP stashes automatically`,
|
|
948
|
+
manual: `git stash push -m "wip: before resuming ${task.key}"`,
|
|
949
|
+
cancel: `Commit or discard your current changes first`,
|
|
950
|
+
},
|
|
951
|
+
} : null,
|
|
952
|
+
requiresConfirmation: !stashBlocks,
|
|
953
|
+
message: stashBlocks
|
|
954
|
+
? `Working tree is dirty — resolve or pass autoStash=true, then call resume_task with confirmed=true.`
|
|
955
|
+
: `Review the context above, then call resume_task again with confirmed=true to switch branches and resume.`,
|
|
956
|
+
})
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── Execute ───────────────────────────────────────────────────────────
|
|
960
|
+
if (!branch) {
|
|
961
|
+
return text({
|
|
962
|
+
resumed: false,
|
|
963
|
+
reason: 'No branch linked to this task yet.',
|
|
964
|
+
nextStep: `Call create_branch with taskId="${taskId}" to create one.`,
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Handle dirty tree
|
|
969
|
+
if (localState === 'modified' && !alreadyOnBranch) {
|
|
970
|
+
if (autoStash) {
|
|
971
|
+
try {
|
|
972
|
+
const stashLabel = `auto-stash(${task.key?.toLowerCase()}): changes on ${currentBranch} before resuming ${branch}`
|
|
973
|
+
runGit(`stash push -m "${stashLabel}"`, repoRoot)
|
|
974
|
+
} catch (e) {
|
|
975
|
+
return text({
|
|
976
|
+
resumed: false,
|
|
977
|
+
reason: `Auto-stash failed: ${e.message.split('\n')[0]}`,
|
|
978
|
+
nextStep: `Manually run: git stash push -m "wip" then retry.`,
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
return text({
|
|
983
|
+
blocked: true,
|
|
984
|
+
reason: `Working tree has ${modified.length} modified file(s). Cannot switch branches safely.`,
|
|
985
|
+
choices: {
|
|
986
|
+
autoStash: `Call resume_task again with confirmed=true and autoStash=true`,
|
|
987
|
+
manual: `git stash push -m "wip: before resuming ${task.key}"`,
|
|
988
|
+
cancel: `Commit or discard your changes first`,
|
|
989
|
+
},
|
|
990
|
+
})
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Switch branch + pull
|
|
995
|
+
let gitResult = null
|
|
996
|
+
if (repoRoot) {
|
|
997
|
+
if (alreadyOnBranch) {
|
|
998
|
+
try {
|
|
999
|
+
const pullOut = runGit(`pull origin ${branch}`, repoRoot)
|
|
1000
|
+
gitResult = { switched: false, alreadyOnBranch: true, pulled: true, pullOutput: pullOut.slice(0, 200) }
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
gitResult = { switched: false, alreadyOnBranch: true, pulled: false, error: e.message.split('\n')[0] }
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
try {
|
|
1006
|
+
runGit('fetch origin', repoRoot)
|
|
1007
|
+
runGit(`checkout ${branch}`, repoRoot)
|
|
1008
|
+
const pullOut = runGit(`pull origin ${branch}`, repoRoot)
|
|
1009
|
+
gitResult = { switched: true, branch, pulled: true, pullOutput: pullOut.slice(0, 200) }
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
gitResult = {
|
|
1012
|
+
switched: false,
|
|
1013
|
+
error: e.message.split('\n')[0],
|
|
1014
|
+
manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}`],
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Restore cursor rules
|
|
1021
|
+
let cursorRulesFile = null
|
|
1022
|
+
if (task?.cursorRules?.trim() && repoRoot) {
|
|
1023
|
+
cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Last commit metadata
|
|
1027
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
1028
|
+
|
|
1029
|
+
return text({
|
|
1030
|
+
resumed: true,
|
|
1031
|
+
task: { key: task.key, title: task.title, column: task.column },
|
|
1032
|
+
git: gitResult || { switched: false, reason: 'No repo found' },
|
|
1033
|
+
autoStashed: autoStash && localState === 'modified' && !alreadyOnBranch
|
|
1034
|
+
? `Changes stashed before switch. Run: git stash list to see them.`
|
|
1035
|
+
: null,
|
|
1036
|
+
lastCommit: lastCommit || null,
|
|
1037
|
+
parkNote: task.parkNote || null,
|
|
1038
|
+
cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
|
|
1039
|
+
message: gitResult?.switched || gitResult?.alreadyOnBranch
|
|
1040
|
+
? `You are on branch "${branch}". Ready to resume coding.`
|
|
1041
|
+
: `Branch switch failed — see git.manualSteps.`,
|
|
1042
|
+
nextStep: task.column === 'in_review'
|
|
1043
|
+
? `Task is in review. Check if PR feedback needs addressing — call fix_pr_feedback if needed.`
|
|
1044
|
+
: task.parkNote?.remaining
|
|
1045
|
+
? `Remaining: ${task.parkNote.remaining}`
|
|
1046
|
+
: `Continue coding on "${branch}".`,
|
|
1047
|
+
})
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
514
1050
|
}
|
|
515
1051
|
|
|
516
1052
|
// ── Standup activity formatter ────────────────────────────────────────────────
|
|
@@ -523,9 +1059,10 @@ function formatActivityLine(a) {
|
|
|
523
1059
|
case 'task.moved': return `Moved from ${m.from} → ${m.to}`
|
|
524
1060
|
case 'task.branch_linked': return `Branch linked: ${m.headBranch}`
|
|
525
1061
|
case 'comment.created': return 'Comment posted'
|
|
526
|
-
case 'approval.submitted':
|
|
527
|
-
case 'approval.approved':
|
|
528
|
-
case 'approval.rejected':
|
|
1062
|
+
case 'approval.submitted': return `Sent for approval → ${m.reviewerName || 'reviewer'}`
|
|
1063
|
+
case 'approval.approved': return `Plan approved${m.note ? ': ' + m.note : ''}`
|
|
1064
|
+
case 'approval.rejected': return `Plan rejected${m.note ? ': ' + m.note : ''}`
|
|
1065
|
+
case 'approval.ai_reviewed': return `AI review submitted by ${m.agentName || 'AI'} — ${m.recommendation || 'needs_work'}`
|
|
529
1066
|
case 'task.merged_from_github': return `PR #${m.prNumber} merged → done`
|
|
530
1067
|
case 'task.pr_opened_to_review': return `PR #${m.prNumber} opened — moved to in_review`
|
|
531
1068
|
case 'task.pr_linked_in_review': return `PR #${m.prNumber} linked (already in review)`
|
|
@@ -1034,8 +1571,9 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1034
1571
|
const subtasksTotal = subtasks.length
|
|
1035
1572
|
|
|
1036
1573
|
// ── Preview: show the full plan before touching anything ──
|
|
1037
|
-
const
|
|
1038
|
-
const
|
|
1574
|
+
const pendingApv = (task.approvals || []).find(a => a.state === 'pending') || null
|
|
1575
|
+
const hasApprovedApv = (task.approvals || []).some(a => a.state === 'approved')
|
|
1576
|
+
const approvalBlocks = ['backlog', 'todo'].includes(task.column) && !hasApprovedApv
|
|
1039
1577
|
|
|
1040
1578
|
// Build the next-step roadmap so developer knows exactly what comes after reading the plan
|
|
1041
1579
|
let workflowRoadmap
|
|
@@ -1068,35 +1606,87 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1068
1606
|
}
|
|
1069
1607
|
|
|
1070
1608
|
// ── 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
1609
|
const alreadyActive = task.column === 'in_progress' && !!task.github?.headBranch
|
|
1074
|
-
const activeAssignees = (task.assignees || []).map(a => a.name || a.email || a.toString())
|
|
1075
1610
|
|
|
1076
1611
|
if (!confirmed && alreadyActive) {
|
|
1612
|
+
// Determine the active owner precisely:
|
|
1613
|
+
// currentOwner = whoever last unparked (actively working now)
|
|
1614
|
+
// parkedBy = whoever parked (task is idle, waiting for pickup)
|
|
1615
|
+
// fallback = assignees list
|
|
1616
|
+
const currentOwner = task.parkNote?.currentOwner
|
|
1617
|
+
const parkedBy = task.parkNote?.parkedBy
|
|
1618
|
+
const isParked = !!task.parkNote?.parkedAt
|
|
1619
|
+
|
|
1620
|
+
const activeOwnerName = currentOwner
|
|
1621
|
+
? (currentOwner.name || currentOwner.email || currentOwner.toString())
|
|
1622
|
+
: null
|
|
1623
|
+
const parkedByName = parkedBy
|
|
1624
|
+
? (parkedBy.name || parkedBy.email || parkedBy.toString())
|
|
1625
|
+
: null
|
|
1626
|
+
const assigneeNames = (task.assignees || []).map(a => a.name || a.email || a.toString())
|
|
1627
|
+
|
|
1628
|
+
// Who is actively coding right now?
|
|
1629
|
+
let activeWorker, situation, advice
|
|
1630
|
+
if (isParked) {
|
|
1631
|
+
activeWorker = null
|
|
1632
|
+
situation = `Task is parked by ${parkedByName || 'a developer'} — no one is actively coding. Safe to pick up via unpark_task.`
|
|
1633
|
+
advice = `Call unpark_task with taskId="${taskId}" to take ownership and continue from where ${parkedByName || 'they'} left off.`
|
|
1634
|
+
} else if (activeOwnerName) {
|
|
1635
|
+
activeWorker = activeOwnerName
|
|
1636
|
+
situation = `${activeOwnerName} is actively working on this task right now.`
|
|
1637
|
+
advice = `Contact ${activeOwnerName} directly before taking over. Do NOT push to branch "${task.github.headBranch}" — it will cause a conflict.`
|
|
1638
|
+
} else if (assigneeNames.length) {
|
|
1639
|
+
activeWorker = assigneeNames.join(', ')
|
|
1640
|
+
situation = `${assigneeNames.join(' and ')} ${assigneeNames.length > 1 ? 'are' : 'is'} assigned to this task and it is in progress.`
|
|
1641
|
+
advice = `Check with ${assigneeNames.join(', ')} before taking over.`
|
|
1642
|
+
} else {
|
|
1643
|
+
activeWorker = null
|
|
1644
|
+
situation = `Task is in progress with no clear owner.`
|
|
1645
|
+
advice = `Check the branch "${task.github.headBranch}" before starting fresh.`
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Notify all project members that someone attempted to take an active task
|
|
1649
|
+
try {
|
|
1650
|
+
const projectRes = await api.get(`/api/projects/${task.project}`)
|
|
1651
|
+
const members = projectRes?.data?.project?.members || []
|
|
1652
|
+
const meRes = await api.get('/api/auth/me')
|
|
1653
|
+
const meName = meRes?.data?.user?.name || meRes?.data?.name || 'Someone'
|
|
1654
|
+
const meId = meRes?.data?.user?._id || meRes?.data?._id || ''
|
|
1655
|
+
for (const memberId of members) {
|
|
1656
|
+
const id = memberId._id || memberId.toString()
|
|
1657
|
+
if (id !== meId) {
|
|
1658
|
+
await api.post('/api/notifications/task-event', {
|
|
1659
|
+
recipient: id,
|
|
1660
|
+
type: 'task_assigned',
|
|
1661
|
+
title: `👀 ${meName} tried to start ${task.key}`,
|
|
1662
|
+
body: `${meName} attempted to kick off "${task.title}" which is already active${activeOwnerName ? ` (owned by ${activeOwnerName})` : ''}.`,
|
|
1663
|
+
link: `/projects/${task.project}/tasks/${taskId}`,
|
|
1664
|
+
taskId,
|
|
1665
|
+
}).catch(() => {/* non-fatal */})
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
} catch { /* non-fatal — don't block the warning */ }
|
|
1669
|
+
|
|
1077
1670
|
return text({
|
|
1078
1671
|
warning: {
|
|
1079
|
-
type:
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
: `Someone may have been working on this. Check the branch before starting fresh.`,
|
|
1672
|
+
type: 'simultaneous_work_detected',
|
|
1673
|
+
situation,
|
|
1674
|
+
activeWorker: activeWorker || '(unknown)',
|
|
1675
|
+
isParked,
|
|
1676
|
+
branch: task.github.headBranch,
|
|
1677
|
+
parkNote: isParked ? task.parkNote : null,
|
|
1678
|
+
advice,
|
|
1087
1679
|
checkoutSteps: [
|
|
1088
1680
|
'git fetch origin',
|
|
1089
1681
|
`git checkout ${task.github.headBranch}`,
|
|
1090
1682
|
`git pull origin ${task.github.headBranch}`,
|
|
1091
1683
|
],
|
|
1092
1684
|
},
|
|
1093
|
-
brief: {
|
|
1094
|
-
key: task.key,
|
|
1095
|
-
title: task.title,
|
|
1096
|
-
priority: task.priority,
|
|
1097
|
-
},
|
|
1685
|
+
brief: { key: task.key, title: task.title, priority: task.priority },
|
|
1098
1686
|
requiresConfirmation: true,
|
|
1099
|
-
message:
|
|
1687
|
+
message: isParked
|
|
1688
|
+
? `Task is parked. Use unpark_task instead of kickoff_task to continue safely.`
|
|
1689
|
+
: `⚠️ ${activeWorker || 'Someone'} is actively working on this. Do not take over without coordinating first. Call kickoff_task with confirmed=true only if you have confirmed with them.`,
|
|
1100
1690
|
})
|
|
1101
1691
|
}
|
|
1102
1692
|
|
|
@@ -1139,6 +1729,32 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1139
1729
|
})
|
|
1140
1730
|
}
|
|
1141
1731
|
|
|
1732
|
+
// ── #1 Preflight: check dirty tree before writing cursor rules / moving task ──
|
|
1733
|
+
{
|
|
1734
|
+
const pCwd = repoPath || process.cwd()
|
|
1735
|
+
const pRoot = findRepoRoot(pCwd)
|
|
1736
|
+
if (pRoot) {
|
|
1737
|
+
try {
|
|
1738
|
+
const porcelain = runGit('status --porcelain=v1', pRoot)
|
|
1739
|
+
const { localState, modified } = parseGitStatus(porcelain)
|
|
1740
|
+
const currentBranchNow = runGit('branch --show-current', pRoot).trim()
|
|
1741
|
+
if (localState === 'modified' && task.github?.headBranch && currentBranchNow !== task.github.headBranch) {
|
|
1742
|
+
return text({
|
|
1743
|
+
blocked: true,
|
|
1744
|
+
reason: `You are on "${currentBranchNow}" with ${modified.length} modified file(s), but this task's branch is "${task.github.headBranch}".`,
|
|
1745
|
+
modifiedFiles: modified,
|
|
1746
|
+
choices: {
|
|
1747
|
+
stash: `git stash push -m "wip: before kickoff ${task.key} on ${currentBranchNow}"`,
|
|
1748
|
+
autoSwitch: `Switch to ${task.github.headBranch}: git stash && git checkout ${task.github.headBranch} && git stash pop`,
|
|
1749
|
+
cancel: 'Review your changes first, then retry kickoff_task',
|
|
1750
|
+
},
|
|
1751
|
+
message: 'Resolve the working tree before kicking off this task to avoid cross-task pollution.',
|
|
1752
|
+
})
|
|
1753
|
+
}
|
|
1754
|
+
} catch { /* non-fatal */ }
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1142
1758
|
// ── Confirmed: move to in_progress and fetch recent commits ──
|
|
1143
1759
|
let recentCommits = []
|
|
1144
1760
|
try {
|
|
@@ -1321,25 +1937,162 @@ function registerIssueTools(server) {
|
|
|
1321
1937
|
function registerApprovalTools(server) {
|
|
1322
1938
|
server.tool(
|
|
1323
1939
|
'submit_task_for_approval',
|
|
1324
|
-
'
|
|
1940
|
+
'Create and submit a new approval request on a task. Each request has its own title, plan/readme, and reviewer. Only one request can be pending at a time.',
|
|
1325
1941
|
{
|
|
1326
1942
|
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1943
|
+
title: z.string().describe('Short title for this approval request, e.g. "Experiment: Add caching layer"'),
|
|
1944
|
+
readme: z.string().describe('The plan/markdown describing what you want to do and why (min 80 chars)'),
|
|
1327
1945
|
reviewerId: z.string().describe('User ID of the reviewer'),
|
|
1328
1946
|
},
|
|
1329
|
-
async ({ taskId, reviewerId }) =>
|
|
1330
|
-
call(() => api.post(`/api/tasks/${taskId}/
|
|
1947
|
+
async ({ taskId, title, readme, reviewerId }) =>
|
|
1948
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals`, { title, readme, reviewerId }))
|
|
1331
1949
|
)
|
|
1332
1950
|
|
|
1333
1951
|
server.tool(
|
|
1334
1952
|
'decide_task_approval',
|
|
1335
|
-
'Approve or reject a
|
|
1953
|
+
'Approve or reject a specific approval request by approvalId. Only the designated reviewer can call this.',
|
|
1336
1954
|
{
|
|
1337
|
-
taskId:
|
|
1338
|
-
|
|
1339
|
-
|
|
1955
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1956
|
+
approvalId: z.string().describe("Approval request's MongoDB ObjectId (from the task's approvals array)"),
|
|
1957
|
+
decision: z.enum(['approve', 'reject']),
|
|
1958
|
+
note: z.string().optional().describe('Reason for the decision'),
|
|
1959
|
+
},
|
|
1960
|
+
async ({ taskId, approvalId, decision, note }) =>
|
|
1961
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals/${approvalId}/decide`, { decision, note }))
|
|
1962
|
+
)
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function registerAIReviewTools(server) {
|
|
1966
|
+
server.tool(
|
|
1967
|
+
'list_pending_reviews',
|
|
1968
|
+
`List all tasks currently pending your review across all your projects.
|
|
1969
|
+
|
|
1970
|
+
Returns task key, title, plan length, who submitted, how long it has been waiting, and whether a PR is linked.
|
|
1971
|
+
Call this when you want to know "what needs reviewing?" or to start a review session.`,
|
|
1972
|
+
{},
|
|
1973
|
+
async () => {
|
|
1974
|
+
const projectsRes = await api.get('/api/projects')
|
|
1975
|
+
const projects = projectsRes?.data?.projects || []
|
|
1976
|
+
|
|
1977
|
+
const boards = await Promise.all(
|
|
1978
|
+
projects.map(async p => {
|
|
1979
|
+
try {
|
|
1980
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
1981
|
+
return { project: r?.data?.project, tasks: r?.data?.tasks || [] }
|
|
1982
|
+
} catch { return null }
|
|
1983
|
+
})
|
|
1984
|
+
)
|
|
1985
|
+
|
|
1986
|
+
const meRes = await api.get('/api/auth/me')
|
|
1987
|
+
const meId = meRes?.data?.user?._id || ''
|
|
1988
|
+
|
|
1989
|
+
const pending = []
|
|
1990
|
+
const now = Date.now()
|
|
1991
|
+
for (const board of boards.filter(Boolean)) {
|
|
1992
|
+
for (const t of board.tasks) {
|
|
1993
|
+
const pendingApv = (t.approvals || []).find(a => a.state === 'pending')
|
|
1994
|
+
if (!pendingApv) continue
|
|
1995
|
+
const reviewerId = pendingApv?.reviewer?._id || pendingApv?.reviewer
|
|
1996
|
+
if (meId && reviewerId && String(reviewerId) !== String(meId)) continue
|
|
1997
|
+
const waitingMs = pendingApv?.requestedAt ? now - new Date(pendingApv.requestedAt).getTime() : 0
|
|
1998
|
+
const waitingHours = Math.round(waitingMs / (1000 * 60 * 60) * 10) / 10
|
|
1999
|
+
pending.push({
|
|
2000
|
+
taskId: t._id,
|
|
2001
|
+
approvalId: pendingApv._id,
|
|
2002
|
+
approvalTitle: pendingApv.title || '',
|
|
2003
|
+
key: t.key,
|
|
2004
|
+
title: t.title,
|
|
2005
|
+
project: board.project?.name || '',
|
|
2006
|
+
priority: t.priority,
|
|
2007
|
+
submittedBy: pendingApv?.requestedBy?.name || pendingApv?.requestedBy?.email || 'unknown',
|
|
2008
|
+
waitingHours,
|
|
2009
|
+
planChars: (pendingApv.readme || '').length,
|
|
2010
|
+
hasPR: !!(t.github?.prNumber),
|
|
2011
|
+
prUrl: t.github?.prUrl || null,
|
|
2012
|
+
hasAIReview: !!pendingApv?.aiReview,
|
|
2013
|
+
aiRecommendation: pendingApv?.aiReview?.recommendation || null,
|
|
2014
|
+
})
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
pending.sort((a, b) => b.waitingHours - a.waitingHours)
|
|
2019
|
+
|
|
2020
|
+
return text({
|
|
2021
|
+
count: pending.length,
|
|
2022
|
+
pendingReviews: pending,
|
|
2023
|
+
message: pending.length === 0
|
|
2024
|
+
? 'No tasks pending your review.'
|
|
2025
|
+
: `${pending.length} task(s) awaiting review. Call get_review_bundle with a taskId to start reviewing.`,
|
|
2026
|
+
})
|
|
2027
|
+
}
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
server.tool(
|
|
2031
|
+
'get_review_bundle',
|
|
2032
|
+
`Get everything needed to review a task as an AI code reviewer.
|
|
2033
|
+
|
|
2034
|
+
Returns:
|
|
2035
|
+
- plan: full implementation README (what the developer planned to build)
|
|
2036
|
+
- prFiles: list of changed files in the PR with unified diffs (the actual code written)
|
|
2037
|
+
- recentCommits: last 20 commits on the task branch
|
|
2038
|
+
- comments: recent discussion on the task
|
|
2039
|
+
- openIssues: any open bugs logged on the task
|
|
2040
|
+
- approval: current approval state and any prior AI review
|
|
2041
|
+
|
|
2042
|
+
After calling this, analyze the plan vs the code diff and call submit_ai_review with your findings.`,
|
|
2043
|
+
{
|
|
2044
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2045
|
+
},
|
|
2046
|
+
async ({ taskId }) => call(() => api.get(`/api/tasks/${taskId}/review-bundle`))
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
server.tool(
|
|
2050
|
+
'submit_ai_review',
|
|
2051
|
+
`Submit a structured AI code review opinion on a specific approval request pending review.
|
|
2052
|
+
|
|
2053
|
+
This does NOT override the human reviewer's decision — it attaches your analysis so the
|
|
2054
|
+
human reviewer can approve/reject faster with full AI context.
|
|
2055
|
+
|
|
2056
|
+
Your review should cover:
|
|
2057
|
+
1. Does the experiment plan make sense and is it safe to implement?
|
|
2058
|
+
2. Are there security concerns, performance issues, or bugs in linked PR code?
|
|
2059
|
+
3. Is the plan clear and actionable?
|
|
2060
|
+
4. Are edge cases covered?
|
|
2061
|
+
|
|
2062
|
+
Workflow:
|
|
2063
|
+
1. Call list_pending_reviews to find tasks awaiting review (returns taskId + approvalId)
|
|
2064
|
+
2. Call get_review_bundle to get the full context (plan + PR diff + commits)
|
|
2065
|
+
3. Analyze everything thoroughly
|
|
2066
|
+
4. Call submit_ai_review with your structured findings`,
|
|
2067
|
+
{
|
|
2068
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2069
|
+
approvalId: z.string().describe("Approval request's MongoDB ObjectId (from list_pending_reviews)"),
|
|
2070
|
+
agentName: z.string().optional().default('Claude Code').describe('Name of the AI agent doing the review'),
|
|
2071
|
+
summary: z.string().describe(
|
|
2072
|
+
'Overall review narrative (2-5 paragraphs). Cover: plan quality, code quality, security, correctness.'
|
|
2073
|
+
),
|
|
2074
|
+
recommendation: z.enum(['approve', 'reject', 'needs_work']).describe(
|
|
2075
|
+
'"approve" = plan is solid; "needs_work" = minor issues; "reject" = significant problems'
|
|
2076
|
+
),
|
|
2077
|
+
confidence: z.number().min(0).max(100).describe(
|
|
2078
|
+
'How confident are you in this recommendation? 0 = very uncertain, 100 = very certain'
|
|
2079
|
+
),
|
|
2080
|
+
checklist: z.array(z.object({
|
|
2081
|
+
item: z.string().describe('What was checked (e.g. "Error handling in API calls")'),
|
|
2082
|
+
passed: z.boolean().describe('Did it pass?'),
|
|
2083
|
+
note: z.string().optional().describe('Brief explanation'),
|
|
2084
|
+
})).describe('Structured checklist of what was reviewed'),
|
|
2085
|
+
issues: z.array(z.object({
|
|
2086
|
+
severity: z.enum(['low', 'medium', 'high', 'critical']),
|
|
2087
|
+
description: z.string().describe('What the issue is'),
|
|
2088
|
+
suggestion: z.string().optional().describe('How to fix it'),
|
|
2089
|
+
})).optional().describe('Specific issues found in the code'),
|
|
1340
2090
|
},
|
|
1341
|
-
async ({ taskId,
|
|
1342
|
-
call(() => api.post(`/api/tasks/${taskId}/
|
|
2091
|
+
async ({ taskId, approvalId, agentName, summary, recommendation, confidence, checklist, issues }) =>
|
|
2092
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals/${approvalId}/ai-review`, {
|
|
2093
|
+
agentName, summary, recommendation, confidence, checklist,
|
|
2094
|
+
issues: issues || [],
|
|
2095
|
+
}))
|
|
1343
2096
|
)
|
|
1344
2097
|
}
|
|
1345
2098
|
|
|
@@ -1489,6 +2242,49 @@ function deleteCursorRulesFile(taskKey, startPath) {
|
|
|
1489
2242
|
}
|
|
1490
2243
|
}
|
|
1491
2244
|
|
|
2245
|
+
// ── #3 Better failure recovery: API call with auto-retry + structured cause ───
|
|
2246
|
+
async function apiWithRetry(fn, maxRetries = 2, initialDelay = 500) {
|
|
2247
|
+
let lastErr
|
|
2248
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2249
|
+
try {
|
|
2250
|
+
return await fn()
|
|
2251
|
+
} catch (e) {
|
|
2252
|
+
lastErr = e
|
|
2253
|
+
const msg = (e.message || '').toLowerCase()
|
|
2254
|
+
// Never retry auth / client errors
|
|
2255
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('forbidden')) {
|
|
2256
|
+
throw Object.assign(e, { _cause: 'auth' })
|
|
2257
|
+
}
|
|
2258
|
+
if (msg.includes('404') || msg.includes('400') || msg.includes('422') || msg.includes('409')) {
|
|
2259
|
+
throw Object.assign(e, { _cause: 'client' })
|
|
2260
|
+
}
|
|
2261
|
+
if (attempt < maxRetries) {
|
|
2262
|
+
await new Promise(r => setTimeout(r, initialDelay * (attempt + 1)))
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
const msg = (lastErr?.message || '').toLowerCase()
|
|
2267
|
+
const cause = (msg.includes('econnref') || msg.includes('network') || msg.includes('fetch failed') || msg.includes('enotfound'))
|
|
2268
|
+
? 'network'
|
|
2269
|
+
: msg.includes('timeout') ? 'timeout' : 'server'
|
|
2270
|
+
lastErr._cause = cause
|
|
2271
|
+
throw lastErr
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function wrapApiError(e) {
|
|
2275
|
+
return { error: true, cause: e._cause || 'unknown', message: e.message?.split('\n')[0] }
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// ── #9 Get last git commit metadata ──────────────────────────────────────────
|
|
2279
|
+
function getLastCommitMeta(repoRoot) {
|
|
2280
|
+
if (!repoRoot) return null
|
|
2281
|
+
try {
|
|
2282
|
+
const raw = runGit('log -1 --format=%H%x09%s%x09%an%x09%ae%x09%ad --date=short', repoRoot)
|
|
2283
|
+
const [sha, subject, authorName, authorEmail, date] = raw.split('\t')
|
|
2284
|
+
return { sha: sha?.slice(0, 7), subject: subject?.trim(), author: authorName || authorEmail, date }
|
|
2285
|
+
} catch { return null }
|
|
2286
|
+
}
|
|
2287
|
+
|
|
1492
2288
|
function parseGitStatus(porcelain) {
|
|
1493
2289
|
const lines = porcelain.split('\n').filter(Boolean)
|
|
1494
2290
|
const staged = lines.filter(l => !' ?!'.includes(l[0])).map(l => ({ xy: l.slice(0, 2), file: l.slice(3) }))
|
|
@@ -1604,18 +2400,24 @@ The developer does not need to run any commands manually — this tool reads loc
|
|
|
1604
2400
|
server.tool(
|
|
1605
2401
|
'list_my_tasks',
|
|
1606
2402
|
`List your assigned tasks sorted by priority (critical → high → medium → low).
|
|
1607
|
-
Each task includes a suggested next git action
|
|
2403
|
+
Each task includes a suggested next git action, ownership status (who currently owns / parked it),
|
|
2404
|
+
and branch consistency warnings so Claude can guide you step by step.
|
|
1608
2405
|
Use this at the start of a session or when switching tasks.`,
|
|
1609
2406
|
{
|
|
1610
2407
|
includeColumns: z.array(
|
|
1611
2408
|
z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done'])
|
|
1612
2409
|
).optional().default(['todo', 'in_progress', 'in_review'])
|
|
1613
2410
|
.describe('Which columns to include. Default excludes backlog and done.'),
|
|
2411
|
+
repoPath: z.string().optional().describe('Absolute path to local repo — used for branch consistency checks'),
|
|
1614
2412
|
},
|
|
1615
|
-
async ({ includeColumns = ['todo', 'in_progress', 'in_review'] } = {}) => {
|
|
2413
|
+
async ({ includeColumns = ['todo', 'in_progress', 'in_review'], repoPath } = {}) => {
|
|
1616
2414
|
const res = await api.get('/api/users/me/tasks')
|
|
1617
2415
|
if (!res?.success) return errorText('Could not fetch tasks')
|
|
1618
2416
|
|
|
2417
|
+
// #4 Local repo for branch consistency checks
|
|
2418
|
+
const cwd = repoPath || process.cwd()
|
|
2419
|
+
const repoRoot = findRepoRoot(cwd)
|
|
2420
|
+
|
|
1619
2421
|
// Server already sorts by priority (critical → low) then updatedAt — just filter and annotate
|
|
1620
2422
|
const tasks = (res.data.tasks || [])
|
|
1621
2423
|
.filter(t => includeColumns.includes(t.column))
|
|
@@ -1630,6 +2432,44 @@ Use this at the start of a session or when switching tasks.`,
|
|
|
1630
2432
|
} else if (t.column === 'in_review') {
|
|
1631
2433
|
suggestedAction = `PR #${t.github?.prNumber || '?'} is open — waiting for reviewer feedback`
|
|
1632
2434
|
}
|
|
2435
|
+
|
|
2436
|
+
// ── #6 Ownership visibility ───────────────────────────────────────
|
|
2437
|
+
const currentOwner = t.parkNote?.currentOwner
|
|
2438
|
+
const parkedBy = t.parkNote?.parkedBy
|
|
2439
|
+
const ownership = {
|
|
2440
|
+
currentOwner: currentOwner
|
|
2441
|
+
? (currentOwner.name || currentOwner.email || String(currentOwner))
|
|
2442
|
+
: null,
|
|
2443
|
+
parkedBy: parkedBy
|
|
2444
|
+
? (parkedBy.name || parkedBy.email || String(parkedBy))
|
|
2445
|
+
: null,
|
|
2446
|
+
parkedAt: t.parkNote?.parkedAt || null,
|
|
2447
|
+
parkedSummary: t.parkNote?.summary || null,
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// ── #4 Branch consistency check ───────────────────────────────────
|
|
2451
|
+
let branchConsistency = null
|
|
2452
|
+
if (t.column === 'in_progress' && t.github?.headBranch && repoRoot) {
|
|
2453
|
+
try {
|
|
2454
|
+
let localExists = false, remoteExists = false
|
|
2455
|
+
try { runGit(`rev-parse --verify ${t.github.headBranch}`, repoRoot); localExists = true } catch { /* not local */ }
|
|
2456
|
+
try { runGit(`rev-parse --verify origin/${t.github.headBranch}`, repoRoot); remoteExists = true } catch { /* not remote */ }
|
|
2457
|
+
if (!localExists && !remoteExists) {
|
|
2458
|
+
branchConsistency = {
|
|
2459
|
+
warning: 'branch_not_found',
|
|
2460
|
+
message: `Branch "${t.github.headBranch}" does not exist locally or remotely.`,
|
|
2461
|
+
fixAction: `git fetch origin && git checkout ${t.github.headBranch} — or use create_branch if it was never pushed`,
|
|
2462
|
+
}
|
|
2463
|
+
} else if (!localExists && remoteExists) {
|
|
2464
|
+
branchConsistency = {
|
|
2465
|
+
warning: 'branch_remote_only',
|
|
2466
|
+
message: `Branch "${t.github.headBranch}" exists on remote but not locally.`,
|
|
2467
|
+
fixAction: `git fetch origin && git checkout ${t.github.headBranch}`,
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
} catch { /* non-fatal */ }
|
|
2471
|
+
}
|
|
2472
|
+
|
|
1633
2473
|
return {
|
|
1634
2474
|
id: t._id,
|
|
1635
2475
|
key: t.key,
|
|
@@ -1639,11 +2479,193 @@ Use this at the start of a session or when switching tasks.`,
|
|
|
1639
2479
|
project: t.project?.name || t.project,
|
|
1640
2480
|
github: t.github ? { headBranch: t.github.headBranch, prNumber: t.github.prNumber, mergedAt: t.github.mergedAt } : null,
|
|
1641
2481
|
parked: !!t.parkNote?.parkedAt,
|
|
2482
|
+
ownership,
|
|
2483
|
+
branchConsistency,
|
|
1642
2484
|
suggestedAction,
|
|
1643
2485
|
}
|
|
1644
2486
|
})
|
|
1645
2487
|
|
|
1646
|
-
|
|
2488
|
+
const consistencyIssues = tasks.filter(t => t.branchConsistency).map(t => ({ key: t.key, ...t.branchConsistency }))
|
|
2489
|
+
|
|
2490
|
+
return text({
|
|
2491
|
+
tasks,
|
|
2492
|
+
count: tasks.length,
|
|
2493
|
+
consistencyIssues: consistencyIssues.length ? consistencyIssues : null,
|
|
2494
|
+
})
|
|
2495
|
+
}
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
// ── #7 find_repo ─────────────────────────────────────────────────────────────
|
|
2499
|
+
server.tool(
|
|
2500
|
+
'find_repo',
|
|
2501
|
+
`Scan the file system to find git repositories near a given path.
|
|
2502
|
+
|
|
2503
|
+
Use this when the user says "I can't find my repo", points to a parent folder,
|
|
2504
|
+
or when create_branch / unpark_task fail because repoPath is wrong.
|
|
2505
|
+
|
|
2506
|
+
Returns all git repos found within 2 directory levels, along with their current branch
|
|
2507
|
+
and whether they contain a .cursor/mcp.json (indicating InternalTool is configured there).`,
|
|
2508
|
+
{
|
|
2509
|
+
searchPath: z.string().optional().describe('Directory to scan from. Defaults to the MCP process working directory.'),
|
|
2510
|
+
},
|
|
2511
|
+
async ({ searchPath } = {}) => {
|
|
2512
|
+
const base = searchPath || process.cwd()
|
|
2513
|
+
const candidates = []
|
|
2514
|
+
|
|
2515
|
+
function tryRepo(dir) {
|
|
2516
|
+
try {
|
|
2517
|
+
const root = runGit('rev-parse --show-toplevel', dir)
|
|
2518
|
+
if (candidates.some(c => c.repoRoot === root)) return // already found
|
|
2519
|
+
let currentBranch = null, hasMcpConfig = false
|
|
2520
|
+
try { currentBranch = runGit('branch --show-current', root).trim() } catch { /* ok */ }
|
|
2521
|
+
try { hasMcpConfig = existsSync(join(root, '.cursor', 'mcp.json')) } catch { /* ok */ }
|
|
2522
|
+
candidates.push({ repoRoot: root, currentBranch, hasMcpConfig })
|
|
2523
|
+
} catch { /* not a git repo */ }
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// Level 0: the path itself
|
|
2527
|
+
tryRepo(base)
|
|
2528
|
+
|
|
2529
|
+
// Level 1: immediate subdirectories
|
|
2530
|
+
try {
|
|
2531
|
+
const entries = readdirSync(base, { withFileTypes: true })
|
|
2532
|
+
for (const e of entries) {
|
|
2533
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
2534
|
+
const sub = join(base, e.name)
|
|
2535
|
+
tryRepo(sub)
|
|
2536
|
+
// Level 2: one more level deep
|
|
2537
|
+
try {
|
|
2538
|
+
const sub2Entries = readdirSync(sub, { withFileTypes: true })
|
|
2539
|
+
for (const e2 of sub2Entries) {
|
|
2540
|
+
if (!e2.isDirectory() || e2.name.startsWith('.')) continue
|
|
2541
|
+
tryRepo(join(sub, e2.name))
|
|
2542
|
+
}
|
|
2543
|
+
} catch { /* ok */ }
|
|
2544
|
+
}
|
|
2545
|
+
} catch { /* ok */ }
|
|
2546
|
+
|
|
2547
|
+
if (!candidates.length) {
|
|
2548
|
+
return text({
|
|
2549
|
+
found: false,
|
|
2550
|
+
searchedFrom: base,
|
|
2551
|
+
message: `No git repositories found within 2 levels of "${base}". Try passing a searchPath closer to your project.`,
|
|
2552
|
+
})
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
return text({
|
|
2556
|
+
found: true,
|
|
2557
|
+
searchedFrom: base,
|
|
2558
|
+
repos: candidates,
|
|
2559
|
+
count: candidates.length,
|
|
2560
|
+
tip: candidates.length === 1
|
|
2561
|
+
? `Use repoPath="${candidates[0].repoRoot}" in create_branch / unpark_task / park_task.`
|
|
2562
|
+
: `Multiple repos found. Pass the correct one as repoPath to git workflow tools.`,
|
|
2563
|
+
})
|
|
2564
|
+
}
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
// ── #8 validate_mcp_config ───────────────────────────────────────────────────
|
|
2568
|
+
server.tool(
|
|
2569
|
+
'validate_mcp_config',
|
|
2570
|
+
`Validate the .cursor/mcp.json configuration for InternalTool.
|
|
2571
|
+
|
|
2572
|
+
Checks for common issues:
|
|
2573
|
+
- File not found or not valid JSON
|
|
2574
|
+
- Missing required fields (command, args, env.INTERNALTOOL_TOKEN)
|
|
2575
|
+
- Trailing spaces or wrong separators in the --url value
|
|
2576
|
+
- Token not starting with "ilt_"
|
|
2577
|
+
- Mismatched project URL vs server URL
|
|
2578
|
+
|
|
2579
|
+
Use this when the MCP connection seems wrong or tools return unexpected auth errors.`,
|
|
2580
|
+
{
|
|
2581
|
+
repoPath: z.string().optional().describe('Path to search for .cursor/mcp.json (defaults to MCP working directory)'),
|
|
2582
|
+
},
|
|
2583
|
+
async ({ repoPath } = {}) => {
|
|
2584
|
+
const cwd = repoPath || process.cwd()
|
|
2585
|
+
const root = findRepoRoot(cwd) || cwd
|
|
2586
|
+
|
|
2587
|
+
const configPath = join(root, '.cursor', 'mcp.json')
|
|
2588
|
+
const issues = []
|
|
2589
|
+
const fixes = []
|
|
2590
|
+
|
|
2591
|
+
if (!existsSync(configPath)) {
|
|
2592
|
+
return text({
|
|
2593
|
+
valid: false,
|
|
2594
|
+
configPath,
|
|
2595
|
+
issues: ['File not found: .cursor/mcp.json does not exist in this repo'],
|
|
2596
|
+
fixes: [
|
|
2597
|
+
`Create it at: ${configPath}`,
|
|
2598
|
+
`Minimal content:\n{\n "mcpServers": {\n "internaltool": {\n "command": "npx",\n "args": ["-y", "internaltool-mcp", "--url", "http://localhost:5001"],\n "env": { "INTERNALTOOL_TOKEN": "ilt_your_token_here" }\n }\n }\n}`,
|
|
2599
|
+
],
|
|
2600
|
+
})
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
let parsed = null
|
|
2604
|
+
try {
|
|
2605
|
+
const { readFileSync: rfs } = await import('fs')
|
|
2606
|
+
const raw = rfs(configPath, 'utf8')
|
|
2607
|
+
// Check for trailing space in path (a common copy-paste mistake on macOS)
|
|
2608
|
+
if (configPath.includes(' ')) {
|
|
2609
|
+
issues.push(`Path contains a space: "${configPath}" — rename the folder to remove it`)
|
|
2610
|
+
fixes.push(`mv "${root}" "${root.replace(/ /g, '_')}"`)
|
|
2611
|
+
}
|
|
2612
|
+
parsed = JSON.parse(raw)
|
|
2613
|
+
} catch (e) {
|
|
2614
|
+
return text({
|
|
2615
|
+
valid: false,
|
|
2616
|
+
configPath,
|
|
2617
|
+
issues: [`Invalid JSON: ${e.message}`],
|
|
2618
|
+
fixes: ['Open .cursor/mcp.json and fix the JSON syntax. Use jsonlint.com to validate.'],
|
|
2619
|
+
})
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
const servers = parsed?.mcpServers || {}
|
|
2623
|
+
const serverNames = Object.keys(servers)
|
|
2624
|
+
if (!serverNames.length) {
|
|
2625
|
+
issues.push('mcpServers object is empty — no server configured')
|
|
2626
|
+
fixes.push('Add an "internaltool" entry under mcpServers')
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
for (const name of serverNames) {
|
|
2630
|
+
const cfg = servers[name]
|
|
2631
|
+
if (!cfg.command) { issues.push(`"${name}": missing "command" field`); fixes.push(`Set "command": "npx"`) }
|
|
2632
|
+
const args = cfg.args || []
|
|
2633
|
+
if (!args.includes('internaltool-mcp') && !args.some(a => a.includes('internaltool-mcp'))) {
|
|
2634
|
+
issues.push(`"${name}": args do not include "internaltool-mcp"`)
|
|
2635
|
+
fixes.push(`args should be: ["-y", "internaltool-mcp", "--url", "<your-server-url>"]`)
|
|
2636
|
+
}
|
|
2637
|
+
const urlIdx = args.indexOf('--url')
|
|
2638
|
+
if (urlIdx === -1) {
|
|
2639
|
+
issues.push(`"${name}": --url flag missing from args`)
|
|
2640
|
+
fixes.push(`Add "--url", "http://localhost:5001" to args`)
|
|
2641
|
+
} else {
|
|
2642
|
+
const urlVal = args[urlIdx + 1] || ''
|
|
2643
|
+
if (urlVal.endsWith(' ') || urlVal.startsWith(' ')) {
|
|
2644
|
+
issues.push(`"${name}": --url value has leading/trailing space: "${urlVal}"`)
|
|
2645
|
+
fixes.push(`Remove spaces: "${urlVal.trim()}"`)
|
|
2646
|
+
}
|
|
2647
|
+
if (!urlVal.startsWith('http://') && !urlVal.startsWith('https://')) {
|
|
2648
|
+
issues.push(`"${name}": --url value looks invalid: "${urlVal}"`)
|
|
2649
|
+
fixes.push(`Should start with http:// or https://`)
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const token = cfg.env?.INTERNALTOOL_TOKEN
|
|
2653
|
+
if (!token) {
|
|
2654
|
+
issues.push(`"${name}": INTERNALTOOL_TOKEN not set in env`)
|
|
2655
|
+
fixes.push(`Add: "env": { "INTERNALTOOL_TOKEN": "ilt_your_token" }`)
|
|
2656
|
+
} else if (!token.startsWith('ilt_')) {
|
|
2657
|
+
issues.push(`"${name}": INTERNALTOOL_TOKEN does not start with "ilt_" — may be wrong`)
|
|
2658
|
+
fixes.push(`Regenerate your token in InternalTool → Settings → API Keys`)
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
return text({
|
|
2663
|
+
valid: issues.length === 0,
|
|
2664
|
+
configPath,
|
|
2665
|
+
serverNames,
|
|
2666
|
+
issues: issues.length ? issues : ['None — config looks correct'],
|
|
2667
|
+
fixes: fixes.length ? fixes : ['No fixes needed'],
|
|
2668
|
+
})
|
|
1647
2669
|
}
|
|
1648
2670
|
)
|
|
1649
2671
|
|
|
@@ -1658,10 +2680,15 @@ Use this when returning to a task after a break, or when Claude needs the full p
|
|
|
1658
2680
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
|
|
1659
2681
|
},
|
|
1660
2682
|
async ({ taskId, repoPath }) => {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2683
|
+
let taskRes, activityRes
|
|
2684
|
+
try {
|
|
2685
|
+
[taskRes, activityRes] = await Promise.all([
|
|
2686
|
+
apiWithRetry(() => api.get(`/api/tasks/${taskId}`)),
|
|
2687
|
+
apiWithRetry(() => api.get(`/api/tasks/${taskId}/activity`)).catch(() => null),
|
|
2688
|
+
])
|
|
2689
|
+
} catch (e) {
|
|
2690
|
+
return text({ error: true, ...wrapApiError(e), message: `Could not fetch task context — ${wrapApiError(e).cause} error. Retry in a moment.` })
|
|
2691
|
+
}
|
|
1665
2692
|
if (!taskRes?.success) return errorText('Task not found')
|
|
1666
2693
|
const task = taskRes.data.task
|
|
1667
2694
|
const recentActivity = (activityRes?.data?.activity || []).slice(-10)
|
|
@@ -1775,8 +2802,9 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1775
2802
|
fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
|
|
1776
2803
|
confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the preview to create the branch'),
|
|
1777
2804
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
|
|
2805
|
+
autoStash: z.boolean().optional().default(false).describe('Auto-stash modified files before creating the branch instead of blocking. Stash is labelled for easy recovery.'),
|
|
1778
2806
|
},
|
|
1779
|
-
async ({ taskId, projectId, fromRef, confirmed = false, repoPath }) => {
|
|
2807
|
+
async ({ taskId, projectId, fromRef, confirmed = false, repoPath, autoStash = false }) => {
|
|
1780
2808
|
if (scopedProjectId && projectId !== scopedProjectId) {
|
|
1781
2809
|
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
1782
2810
|
}
|
|
@@ -1788,9 +2816,9 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1788
2816
|
// ── Approval gate check ───────────────────────────────────────────────────
|
|
1789
2817
|
// The server blocks non-admins from moving todo → in_progress without approval.
|
|
1790
2818
|
// Detect this early and guide the developer instead of failing silently after branch creation.
|
|
1791
|
-
const
|
|
2819
|
+
const hasApprovedApv2 = (task.approvals || []).some(a => a.state === 'approved')
|
|
1792
2820
|
const PLANNING_COLS = ['backlog', 'todo']
|
|
1793
|
-
const needsApproval = PLANNING_COLS.includes(task.column) &&
|
|
2821
|
+
const needsApproval = PLANNING_COLS.includes(task.column) && !hasApprovedApv2
|
|
1794
2822
|
if (needsApproval && !confirmed) {
|
|
1795
2823
|
const isFix2 = /\b(fix|bug|hotfix|patch)\b/i.test(task.title + ' ' + (task.description || ''))
|
|
1796
2824
|
const slug2 = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 35)
|
|
@@ -1894,18 +2922,32 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1894
2922
|
})
|
|
1895
2923
|
}
|
|
1896
2924
|
|
|
1897
|
-
// Step 2 — block
|
|
2925
|
+
// Step 2 — block or auto-stash if there are tracked changes
|
|
1898
2926
|
if (gitState?.localState === 'modified') {
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
2927
|
+
if (autoStash) {
|
|
2928
|
+
try {
|
|
2929
|
+
const stashLabel = `auto-stash(${task.key?.toLowerCase()}): before create_branch ${branchName}`
|
|
2930
|
+
runGit(`stash push -m "${stashLabel}"`, cwd)
|
|
2931
|
+
} catch (stashErr) {
|
|
2932
|
+
return text({
|
|
2933
|
+
blocked: true,
|
|
2934
|
+
reason: `autoStash failed: ${stashErr.message.split('\n')[0]}`,
|
|
2935
|
+
message: 'Stash your changes manually and retry.',
|
|
2936
|
+
})
|
|
2937
|
+
}
|
|
2938
|
+
} else {
|
|
2939
|
+
return text({
|
|
2940
|
+
blocked: true,
|
|
2941
|
+
reason: `You have ${gitState.modified.length} modified tracked file(s). Creating a branch while tracked changes are present can carry them onto the new branch.`,
|
|
2942
|
+
modifiedFiles: gitState.modified,
|
|
2943
|
+
fixOptions: {
|
|
2944
|
+
stash: `git stash push -m "wip: switching to ${branchName}"`,
|
|
2945
|
+
commit: 'git add . && git commit -m "chore: wip"',
|
|
2946
|
+
autoStash: `Call create_branch again with autoStash=true — MCP will stash automatically`,
|
|
2947
|
+
},
|
|
2948
|
+
message: 'Stash or commit your changes, then call create_branch again with confirmed=true.',
|
|
2949
|
+
})
|
|
2950
|
+
}
|
|
1909
2951
|
}
|
|
1910
2952
|
|
|
1911
2953
|
// Step 3 — create branch on GitHub (or confirm it already exists), then link + move
|
|
@@ -2242,10 +3284,27 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
|
|
|
2242
3284
|
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
2243
3285
|
}
|
|
2244
3286
|
|
|
2245
|
-
|
|
3287
|
+
let taskRes
|
|
3288
|
+
try { taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`)) }
|
|
3289
|
+
catch (e) { return text({ error: true, ...wrapApiError(e), message: `Could not fetch task — ${wrapApiError(e).cause} error. Retry raise_pr in a moment.` }) }
|
|
2246
3290
|
if (!taskRes?.success) return errorText('Task not found')
|
|
2247
3291
|
const task = taskRes.data.task
|
|
2248
3292
|
|
|
3293
|
+
// ── #5 Idempotent: return existing PR instead of trying to recreate ─────
|
|
3294
|
+
if (task.github?.prUrl && task.github?.prNumber) {
|
|
3295
|
+
const msg = confirmed
|
|
3296
|
+
? `PR #${task.github.prNumber} already exists for ${task.key} — no duplicate created.`
|
|
3297
|
+
: `PR #${task.github.prNumber} already exists for this branch. No action needed.`
|
|
3298
|
+
return text({
|
|
3299
|
+
prAlreadyExists: true,
|
|
3300
|
+
prNumber: task.github.prNumber,
|
|
3301
|
+
prUrl: task.github.prUrl,
|
|
3302
|
+
headBranch: task.github.headBranch || headBranch,
|
|
3303
|
+
message: msg,
|
|
3304
|
+
nextStep: `PR is live at ${task.github.prUrl}. The GitHub webhook tracks review status automatically.`,
|
|
3305
|
+
})
|
|
3306
|
+
}
|
|
3307
|
+
|
|
2249
3308
|
const prTitle = `[${task.key}] ${task.title}`
|
|
2250
3309
|
const bodyParts = [
|
|
2251
3310
|
`## ${task.key}: ${task.title}`,
|
|
@@ -2453,6 +3512,32 @@ branchAction values (only needed when current branch ≠ task branch):
|
|
|
2453
3512
|
...unsafeUntracked.map(f => `⚠️ SKIP: ${f} ← do not commit this`),
|
|
2454
3513
|
]
|
|
2455
3514
|
|
|
3515
|
+
// ── Block if task is parked — prevent Dev A pushing after handoff ────────
|
|
3516
|
+
if (task) {
|
|
3517
|
+
const isParked = !!(task.parkNote?.parkedAt)
|
|
3518
|
+
const currentOwner = task.parkNote?.currentOwner
|
|
3519
|
+
const parkedBy = task.parkNote?.parkedBy
|
|
3520
|
+
let meId = ''
|
|
3521
|
+
try { const me = await api.get('/api/auth/me'); meId = me?.data?.user?._id || me?.data?._id || '' } catch { /* non-fatal */ }
|
|
3522
|
+
|
|
3523
|
+
if (isParked && parkedBy && parkedBy.toString() === meId) {
|
|
3524
|
+
return text({
|
|
3525
|
+
blocked: true,
|
|
3526
|
+
reason: `You parked "${task.title}". Another developer may have picked it up.`,
|
|
3527
|
+
advice: 'Check the task on the board before pushing. If you still own it, call unpark_task first, then commit.',
|
|
3528
|
+
message: 'Commit blocked — task is parked.',
|
|
3529
|
+
})
|
|
3530
|
+
}
|
|
3531
|
+
if (currentOwner && currentOwner.toString() !== meId && meId) {
|
|
3532
|
+
return text({
|
|
3533
|
+
blocked: true,
|
|
3534
|
+
reason: `"${task.title}" is currently owned by another developer who unparked it.`,
|
|
3535
|
+
advice: 'Do not push to this branch. Coordinate with the current owner first.',
|
|
3536
|
+
message: 'Commit blocked — another developer owns this task.',
|
|
3537
|
+
})
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
|
|
2456
3541
|
// ── PREVIEW (confirmed=false) ─────────────────────────────────────────────
|
|
2457
3542
|
if (!confirmed) {
|
|
2458
3543
|
// Case 1: branch mismatch — must resolve before anything else
|
|
@@ -2754,6 +3839,7 @@ async function main() {
|
|
|
2754
3839
|
registerProductivityTools(server)
|
|
2755
3840
|
registerIssueTools(server)
|
|
2756
3841
|
registerApprovalTools(server)
|
|
3842
|
+
registerAIReviewTools(server)
|
|
2757
3843
|
registerCommentTools(server)
|
|
2758
3844
|
registerNotificationTools(server)
|
|
2759
3845
|
registerGithubTools(server, ctx)
|