internaltool-mcp 1.6.19 → 1.6.24
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 +1709 -70
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -397,10 +397,16 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
|
|
|
397
397
|
cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
// ── #9 Capture last commit for handoff metadata ───────────────────────
|
|
401
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
402
|
+
|
|
400
403
|
return text({
|
|
401
404
|
parked: true,
|
|
402
405
|
task: { key: task?.key, title: task?.title, branch: task?.github?.headBranch || null },
|
|
403
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,
|
|
404
410
|
cursorRulesCleared: cursorRulesCleared ? `Deleted ${cursorRulesCleared}` : null,
|
|
405
411
|
commentPosted: true,
|
|
406
412
|
teamNotified: true,
|
|
@@ -427,8 +433,11 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
427
433
|
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
428
434
|
confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the park note'),
|
|
429
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.'),
|
|
437
|
+
agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
|
|
438
|
+
.describe('Set the agent role for this task session. Role-specific behavioral constraints are injected into cursor rules.'),
|
|
430
439
|
},
|
|
431
|
-
async ({ taskId, confirmed = false, repoPath }) => {
|
|
440
|
+
async ({ taskId, confirmed = false, repoPath, autoStash = false, agentRole }) => {
|
|
432
441
|
const taskRes = await api.get(`/api/tasks/${taskId}`)
|
|
433
442
|
const task = taskRes?.data?.task
|
|
434
443
|
const branch = task?.github?.headBranch || null
|
|
@@ -480,6 +489,35 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
480
489
|
const repoRoot = findRepoRoot(cwd)
|
|
481
490
|
let gitResult = null
|
|
482
491
|
|
|
492
|
+
// ── #1/#2 Preflight: repo readiness guard + autoStash ─────────────────
|
|
493
|
+
if (repoRoot && branch) {
|
|
494
|
+
try {
|
|
495
|
+
const porcelain = runGit('status --porcelain=v1', repoRoot)
|
|
496
|
+
const { localState, modified } = parseGitStatus(porcelain)
|
|
497
|
+
const currentBranchNow = runGit('branch --show-current', repoRoot).trim()
|
|
498
|
+
if (localState === 'modified') {
|
|
499
|
+
if (autoStash) {
|
|
500
|
+
// Auto-stash with a clear label so it can be recovered later
|
|
501
|
+
const stashLabel = `auto-stash(${task?.key?.toLowerCase() || 'unpark'}): changes on ${currentBranchNow} before switching to ${branch}`
|
|
502
|
+
runGit(`stash push -m "${stashLabel}"`, repoRoot)
|
|
503
|
+
} else {
|
|
504
|
+
return text({
|
|
505
|
+
blocked: true,
|
|
506
|
+
reason: `Working tree has ${modified.length} modified file(s) on "${currentBranchNow}" — cannot switch branches safely.`,
|
|
507
|
+
modifiedFiles: modified,
|
|
508
|
+
choices: {
|
|
509
|
+
stash: `git stash push -m "wip: before unpark ${task?.key || ''} on ${currentBranchNow}"`,
|
|
510
|
+
commit: `git add . && git commit -m "wip(${task?.key?.toLowerCase() || 'task'}): before unpark"`,
|
|
511
|
+
autoStash: `Call unpark_task again with autoStash=true — MCP will stash automatically with a labelled entry`,
|
|
512
|
+
cancel: 'Review and handle your changes, then retry',
|
|
513
|
+
},
|
|
514
|
+
message: 'Handle the working tree before switching branches. Tip: use autoStash=true to let MCP do it automatically.',
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch { /* non-fatal — proceed with checkout anyway */ }
|
|
519
|
+
}
|
|
520
|
+
|
|
483
521
|
if (repoRoot && branch) {
|
|
484
522
|
try {
|
|
485
523
|
runGit('fetch origin', repoRoot)
|
|
@@ -510,23 +548,955 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
|
|
|
510
548
|
// ── Clear park note + server-side comment & notification ──────────────
|
|
511
549
|
await api.patch(`/api/tasks/${taskId}/unpark`, {})
|
|
512
550
|
|
|
513
|
-
// ── Restore cursor rules file
|
|
551
|
+
// ── Restore cursor rules file (with role injection if set) ───────────────
|
|
514
552
|
let cursorRulesFile = null
|
|
515
|
-
|
|
516
|
-
|
|
553
|
+
const hasCursorRulesUnpark = task?.cursorRules?.trim()
|
|
554
|
+
if (hasCursorRulesUnpark || agentRole) {
|
|
555
|
+
cursorRulesFile = writeCursorRulesFile(
|
|
556
|
+
task.key,
|
|
557
|
+
hasCursorRulesUnpark || '(No task-specific rules — follow role constraints above.)',
|
|
558
|
+
repoPath,
|
|
559
|
+
agentRole || null
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Persist agentRole to server if set
|
|
564
|
+
if (agentRole) {
|
|
565
|
+
api.patch(`/api/tasks/${taskId}`, { agentRole }).catch(() => {/* non-fatal */})
|
|
517
566
|
}
|
|
518
567
|
|
|
568
|
+
// ── #9 Last commit metadata for handoff context ───────────────────────
|
|
569
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
570
|
+
|
|
519
571
|
return text({
|
|
520
572
|
unparked: true,
|
|
521
|
-
task: { key: task?.key, title: task?.title },
|
|
573
|
+
task: { key: task?.key, title: task?.title, agentRole: agentRole || null },
|
|
522
574
|
git: gitResult || { switched: false, reason: 'No branch linked or repo not found' },
|
|
523
|
-
|
|
575
|
+
autoStashed: autoStash ? 'Changes were auto-stashed before branch switch. Run: git stash list to see them.' : null,
|
|
576
|
+
lastCommit: lastCommit || null,
|
|
577
|
+
cursorRules: cursorRulesFile
|
|
578
|
+
? { restored: true, path: cursorRulesFile, agentRole: agentRole || null }
|
|
579
|
+
: { restored: false },
|
|
524
580
|
commentPosted: true,
|
|
525
581
|
previousDevNotified: true,
|
|
526
582
|
parkNote: task?.parkNote || null,
|
|
527
583
|
message: gitResult?.switched
|
|
528
|
-
? `You are now on branch "${branch}". Cursor rules restored. Start coding from where ${task?.parkNote?.parkedBy ? 'the previous developer' : 'you'} left off.`
|
|
584
|
+
? `You are now on branch "${branch}". Cursor rules restored${agentRole ? ` (role: ${agentRole})` : ''}. Start coding from where ${task?.parkNote?.parkedBy ? 'the previous developer' : 'you'} left off.`
|
|
529
585
|
: `Branch switch failed — see git.manualSteps. Cursor rules restored.`,
|
|
586
|
+
nextStep: agentRole === 'builder'
|
|
587
|
+
? `BUILDER role active. Call claim_files to lock your files before editing.`
|
|
588
|
+
: agentRole === 'scout'
|
|
589
|
+
? `SCOUT role active. Read-only mode — map the codebase and save findings with update_task(scoutReport=...).`
|
|
590
|
+
: agentRole === 'coordinator'
|
|
591
|
+
? `COORDINATOR role active. Call decompose_task to plan parallel workstreams.`
|
|
592
|
+
: agentRole === 'reviewer'
|
|
593
|
+
? `REVIEWER role active. Call review_pr to start the review chain.`
|
|
594
|
+
: null,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
// ── claim_files ──────────────────────────────────────────────────────────────
|
|
600
|
+
server.tool(
|
|
601
|
+
'claim_files',
|
|
602
|
+
`Claim exclusive ownership of files for this task session.
|
|
603
|
+
|
|
604
|
+
Prevents merge conflicts by ensuring no two in-progress tasks edit the same file.
|
|
605
|
+
Call this at the start of a builder session before editing any files.
|
|
606
|
+
|
|
607
|
+
Returns a conflict list if any claimed files are already owned by another in-progress task.
|
|
608
|
+
Ownership is automatically released when the task is parked.
|
|
609
|
+
|
|
610
|
+
Roles: BUILDER agents MUST call this before editing. Coordinators call this as part of decompose_task.`,
|
|
611
|
+
{
|
|
612
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
613
|
+
files: z.array(z.string()).min(1).describe('List of file paths this task will exclusively edit (e.g. ["server/routes/tasks.js", "client/src/App.jsx"])'),
|
|
614
|
+
},
|
|
615
|
+
async ({ taskId, files }) => {
|
|
616
|
+
const res = await api.post(`/api/tasks/${taskId}/files/claim`, { files })
|
|
617
|
+
if (!res?.success) {
|
|
618
|
+
if (res?.conflicts) {
|
|
619
|
+
return text({
|
|
620
|
+
blocked: true,
|
|
621
|
+
reason: 'File ownership conflict — these files are already claimed by another in-progress task.',
|
|
622
|
+
conflicts: res.conflicts,
|
|
623
|
+
message: 'Wait for the conflicting task to release the files, or coordinate with the other developer to resolve the overlap.',
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
return errorText(res?.message || 'Could not claim files')
|
|
627
|
+
}
|
|
628
|
+
return text({
|
|
629
|
+
claimed: true,
|
|
630
|
+
files,
|
|
631
|
+
taskId,
|
|
632
|
+
message: `${files.length} file(s) claimed. No other in-progress task in this project may edit these files until you release or park.`,
|
|
633
|
+
nextStep: 'You may now safely edit the claimed files. Files are auto-released when you call park_task.',
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
// ── release_files ─────────────────────────────────────────────────────────────
|
|
639
|
+
server.tool(
|
|
640
|
+
'release_files',
|
|
641
|
+
`Release all file ownership claims for a task.
|
|
642
|
+
|
|
643
|
+
Call this when a builder subtask is complete and another task needs to edit the same files.
|
|
644
|
+
Files are also released automatically when the task is parked.`,
|
|
645
|
+
{
|
|
646
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
647
|
+
},
|
|
648
|
+
async ({ taskId }) => {
|
|
649
|
+
const res = await api.post(`/api/tasks/${taskId}/files/release`, {})
|
|
650
|
+
if (!res?.success) return errorText(res?.message || 'Could not release files')
|
|
651
|
+
return text({
|
|
652
|
+
released: true,
|
|
653
|
+
taskId,
|
|
654
|
+
message: 'All file claims released. Other tasks may now claim these files.',
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
// ── decompose_task ────────────────────────────────────────────────────────────
|
|
660
|
+
server.tool(
|
|
661
|
+
'decompose_task',
|
|
662
|
+
`Coordinator tool: decompose an implementation plan into parallel subtasks with file ownership and role assignments.
|
|
663
|
+
|
|
664
|
+
Use this when you are acting as a COORDINATOR agent and need to break a large task into
|
|
665
|
+
parallel workstreams that multiple builder agents can execute without stepping on each other.
|
|
666
|
+
|
|
667
|
+
What this does:
|
|
668
|
+
1. Reads the task's implementation plan (README)
|
|
669
|
+
2. Analyzes the plan to identify independent work units
|
|
670
|
+
3. Returns a structured execution plan with:
|
|
671
|
+
- Parallel subtask groups (tasks that can run simultaneously)
|
|
672
|
+
- File ownership per subtask (no overlaps — each file appears in exactly one subtask)
|
|
673
|
+
- Role assignment per subtask (builder/scout/reviewer)
|
|
674
|
+
- Dependency order (which subtasks must complete before others start)
|
|
675
|
+
4. Saves the decomposition plan to the task (decomposition field)
|
|
676
|
+
5. Optionally creates the subtasks on the board
|
|
677
|
+
|
|
678
|
+
REQUIRED BEFORE BUILDERS START:
|
|
679
|
+
- A scout report should exist (call kickoff_task with agentRole=scout first)
|
|
680
|
+
- The README must describe what needs to be built
|
|
681
|
+
|
|
682
|
+
Call confirmed=false to preview the decomposition, confirmed=true to save it.`,
|
|
683
|
+
{
|
|
684
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
685
|
+
subtaskPlan: z.array(z.object({
|
|
686
|
+
title: z.string().describe('Subtask title'),
|
|
687
|
+
role: z.enum(['builder', 'scout', 'reviewer']).describe('Agent role for this subtask'),
|
|
688
|
+
files: z.array(z.string()).describe('Files this subtask exclusively owns (no overlap with other subtasks)'),
|
|
689
|
+
description: z.string().describe('What this subtask implements'),
|
|
690
|
+
dependsOn: z.array(z.string()).optional().describe('Titles of subtasks that must complete before this one starts'),
|
|
691
|
+
parallel: z.boolean().optional().default(true).describe('Can this subtask run in parallel with others at the same level?'),
|
|
692
|
+
})).min(1).describe('Decomposed subtask execution plan. File ownership must not overlap between subtasks.'),
|
|
693
|
+
confirmed: z.boolean().optional().default(false).describe('Set true to save the decomposition and create subtasks on the board'),
|
|
694
|
+
},
|
|
695
|
+
async ({ taskId, subtaskPlan, confirmed = false }) => {
|
|
696
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
697
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
698
|
+
const task = taskRes.data.task
|
|
699
|
+
|
|
700
|
+
// Validate: no file overlap between subtasks
|
|
701
|
+
const fileOwnershipMap = {}
|
|
702
|
+
const conflicts = []
|
|
703
|
+
for (const subtask of subtaskPlan) {
|
|
704
|
+
for (const file of (subtask.files || [])) {
|
|
705
|
+
if (fileOwnershipMap[file]) {
|
|
706
|
+
conflicts.push({ file, claimedBy: [fileOwnershipMap[file], subtask.title] })
|
|
707
|
+
} else {
|
|
708
|
+
fileOwnershipMap[file] = subtask.title
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (conflicts.length > 0) {
|
|
714
|
+
return text({
|
|
715
|
+
blocked: true,
|
|
716
|
+
reason: 'File ownership overlap detected — each file must appear in exactly one subtask.',
|
|
717
|
+
conflicts,
|
|
718
|
+
message: 'Fix the overlapping file assignments before confirming the decomposition.',
|
|
719
|
+
})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Group subtasks by dependency level for parallel execution visualization
|
|
723
|
+
const parallelGroups = []
|
|
724
|
+
const placed = new Set()
|
|
725
|
+
let safetyLimit = subtaskPlan.length + 1
|
|
726
|
+
while (placed.size < subtaskPlan.length && safetyLimit-- > 0) {
|
|
727
|
+
const batch = subtaskPlan.filter(s =>
|
|
728
|
+
!placed.has(s.title) &&
|
|
729
|
+
(s.dependsOn || []).every(dep => placed.has(dep))
|
|
730
|
+
)
|
|
731
|
+
if (!batch.length) break
|
|
732
|
+
parallelGroups.push(batch.map(s => s.title))
|
|
733
|
+
batch.forEach(s => placed.add(s.title))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const executionPlan = {
|
|
737
|
+
taskKey: task.key,
|
|
738
|
+
taskTitle: task.title,
|
|
739
|
+
totalSubtasks: subtaskPlan.length,
|
|
740
|
+
subtasks: subtaskPlan.map(s => ({
|
|
741
|
+
title: s.title,
|
|
742
|
+
role: s.role,
|
|
743
|
+
files: s.files,
|
|
744
|
+
description: s.description,
|
|
745
|
+
dependsOn: s.dependsOn || [],
|
|
746
|
+
parallel: s.parallel !== false,
|
|
747
|
+
})),
|
|
748
|
+
executionOrder: parallelGroups,
|
|
749
|
+
fileOwnershipMap,
|
|
750
|
+
scoutReportPresent: !!(task.scoutReport?.trim()),
|
|
751
|
+
readmePresent: !!(task.readmeMarkdown?.trim()),
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (!confirmed) {
|
|
755
|
+
return text({
|
|
756
|
+
decomposition: executionPlan,
|
|
757
|
+
warnings: [
|
|
758
|
+
!executionPlan.scoutReportPresent ? '⚠️ No scout report on this task. Consider running a scout agent first to map the codebase before builders start.' : null,
|
|
759
|
+
!executionPlan.readmePresent ? '⚠️ No implementation plan (README). Builders need a spec to work from.' : null,
|
|
760
|
+
].filter(Boolean),
|
|
761
|
+
requiresConfirmation: true,
|
|
762
|
+
message: 'Review the decomposition above. Call decompose_task again with confirmed=true to save it and create the subtasks on the board.',
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Save decomposition to task
|
|
767
|
+
const decompositionJson = JSON.stringify(executionPlan, null, 2)
|
|
768
|
+
try {
|
|
769
|
+
await api.patch(`/api/tasks/${taskId}`, { decomposition: decompositionJson })
|
|
770
|
+
} catch { /* non-fatal — decomposition is returned regardless */ }
|
|
771
|
+
|
|
772
|
+
// Create subtasks on the board
|
|
773
|
+
const currentSubtasks = task.subtasks || []
|
|
774
|
+
const newSubtasks = [
|
|
775
|
+
...currentSubtasks,
|
|
776
|
+
...subtaskPlan.map((s, i) => ({
|
|
777
|
+
title: `[${s.role.toUpperCase()}] ${s.title}`,
|
|
778
|
+
done: false,
|
|
779
|
+
order: currentSubtasks.length + i,
|
|
780
|
+
})),
|
|
781
|
+
]
|
|
782
|
+
try {
|
|
783
|
+
await api.patch(`/api/tasks/${taskId}`, { subtasks: newSubtasks })
|
|
784
|
+
} catch { /* non-fatal */ }
|
|
785
|
+
|
|
786
|
+
return text({
|
|
787
|
+
decomposed: true,
|
|
788
|
+
taskKey: task.key,
|
|
789
|
+
executionPlan,
|
|
790
|
+
subtasksCreated: subtaskPlan.length,
|
|
791
|
+
message: `Decomposition saved. ${subtaskPlan.length} subtask(s) added to the board.`,
|
|
792
|
+
nextStep: parallelGroups.length > 0
|
|
793
|
+
? `Start with parallel group 1: ${parallelGroups[0].join(', ')}. Each builder agent should call kickoff_task with agentRole="builder" and claim_files before coding.`
|
|
794
|
+
: 'Start the subtasks in order. Each builder should call kickoff_task with agentRole="builder" and claim_files.',
|
|
795
|
+
})
|
|
796
|
+
}
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
// ── scout_task ────────────────────────────────────────────────────────────────
|
|
800
|
+
server.tool(
|
|
801
|
+
'scout_task',
|
|
802
|
+
`Scout agent entry point: analyze the codebase for a task and save a structured scout report.
|
|
803
|
+
|
|
804
|
+
Call this BEFORE builders start when the Coordinator needs a codebase map.
|
|
805
|
+
Use agentRole="scout" in kickoff_task first, then call scout_task to get your briefing.
|
|
806
|
+
|
|
807
|
+
Two-phase flow:
|
|
808
|
+
Phase 1 — confirmed=false: get the briefing (what to analyze, report format)
|
|
809
|
+
Phase 2 — confirmed=true + report: save your findings to the task
|
|
810
|
+
|
|
811
|
+
The scout report is consumed by:
|
|
812
|
+
- decompose_task (warns if missing)
|
|
813
|
+
- get_agent_context (included in builder context)
|
|
814
|
+
- kickoff_task (surfaced as part of builder brief)
|
|
815
|
+
|
|
816
|
+
Scouts MUST NOT modify any source code files or create branches.`,
|
|
817
|
+
{
|
|
818
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
819
|
+
confirmed: z.boolean().optional().default(false).describe('Set true to save the report. Set false (default) to get the briefing on what to analyze.'),
|
|
820
|
+
report: z.string().optional().default('').describe('Your structured scout findings in markdown (required when confirmed=true)'),
|
|
821
|
+
},
|
|
822
|
+
async ({ taskId, confirmed = false, report = '' }) => {
|
|
823
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
824
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
825
|
+
const task = taskRes.data.task
|
|
826
|
+
|
|
827
|
+
if (!confirmed) {
|
|
828
|
+
return text({
|
|
829
|
+
brief: {
|
|
830
|
+
key: task.key,
|
|
831
|
+
title: task.title,
|
|
832
|
+
implementationPlan: task.readmeMarkdown || '(no README — ask coordinator to write one first)',
|
|
833
|
+
},
|
|
834
|
+
currentScoutReport: task.scoutReport?.trim() || null,
|
|
835
|
+
scoutInstructions: {
|
|
836
|
+
objective: 'Map the codebase as it relates to this task. Identify every file that will need to change.',
|
|
837
|
+
analyzeThese: [
|
|
838
|
+
'Entry points — where does execution start for this feature area?',
|
|
839
|
+
'File dependency graph — which files import from which?',
|
|
840
|
+
'Existing patterns — what conventions does this codebase use (naming, folder structure, abstractions)?',
|
|
841
|
+
'Conflict risks — are any in-progress tasks touching the same files?',
|
|
842
|
+
'Complexity estimate — lines of code, number of files, coupling, test coverage',
|
|
843
|
+
],
|
|
844
|
+
reportFormat: {
|
|
845
|
+
summary: 'One paragraph overview of what this task touches in the codebase',
|
|
846
|
+
entryPoints: ['file/path/entry.ts', '...'],
|
|
847
|
+
keyFiles: [{ path: 'file/path.ts', role: 'what it does for this task' }],
|
|
848
|
+
risks: ['Potential gotchas, breaking changes, or hidden complexity'],
|
|
849
|
+
suggestedFileOwnership: { 'Subtask Name': ['file1.ts', 'file2.ts'] },
|
|
850
|
+
complexityEstimate: 'low | medium | high',
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
requiresConfirmation: true,
|
|
854
|
+
message: `Analyze the codebase for ${task.key}, then call scout_task again with confirmed=true and your report string.`,
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!report.trim()) return errorText('report is required when confirmed=true. Pass your scout findings as a markdown string.')
|
|
859
|
+
|
|
860
|
+
try {
|
|
861
|
+
await api.patch(`/api/tasks/${taskId}`, { scoutReport: report })
|
|
862
|
+
} catch (e) {
|
|
863
|
+
return errorText(`Failed to save scout report: ${e?.message || 'unknown error'}`)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return text({
|
|
867
|
+
saved: true,
|
|
868
|
+
taskKey: task.key,
|
|
869
|
+
message: `Scout report saved for ${task.key}.`,
|
|
870
|
+
nextStep: 'The coordinator can now call decompose_task — the scout report will be included in builder context automatically via get_agent_context.',
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
// ── get_agent_context ─────────────────────────────────────────────────────────
|
|
876
|
+
server.tool(
|
|
877
|
+
'get_agent_context',
|
|
878
|
+
`Get a complete context package for starting work on a task as a specific agent role.
|
|
879
|
+
|
|
880
|
+
Call this instead of get_task when starting a session — it returns everything composed and
|
|
881
|
+
ready to use: systemPrompt, role behavioral constraints, implementation plan, cursor rules,
|
|
882
|
+
scout report, decomposition plan, claimed files, and project-level custom guidance.
|
|
883
|
+
|
|
884
|
+
Use this as the single entry point for any agent picking up a task. Replaces the need to
|
|
885
|
+
call get_task + kickoff_task preview separately.
|
|
886
|
+
|
|
887
|
+
Returns systemPrompt ready to use as a Claude system prompt.`,
|
|
888
|
+
{
|
|
889
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
890
|
+
role: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
|
|
891
|
+
.describe('Agent role for this session. Falls back to task.agentRole if omitted.'),
|
|
892
|
+
},
|
|
893
|
+
async ({ taskId, role }) => {
|
|
894
|
+
const qs = role ? `?role=${role}` : ''
|
|
895
|
+
const res = await apiWithRetry(() => api.get(`/api/tasks/${taskId}/agent-context${qs}`))
|
|
896
|
+
if (!res?.success) return errorText(res?.message || 'Failed to get agent context')
|
|
897
|
+
const ctx = res.data
|
|
898
|
+
|
|
899
|
+
const effectiveRole = ctx.role
|
|
900
|
+
const roleRules = effectiveRole && ROLE_RULES[effectiveRole] ? ROLE_RULES[effectiveRole] : null
|
|
901
|
+
|
|
902
|
+
// Compose the full system prompt
|
|
903
|
+
const parts = []
|
|
904
|
+
if (effectiveRole) {
|
|
905
|
+
const roleLabel = ctx.customRoleLabel || effectiveRole.toUpperCase()
|
|
906
|
+
parts.push(`You are a ${roleLabel} agent working on task ${ctx.taskKey}: "${ctx.taskTitle}".`)
|
|
907
|
+
}
|
|
908
|
+
if (roleRules) parts.push(roleRules)
|
|
909
|
+
if (ctx.customPromptHint) parts.push(`\n## Project-Specific Guidance\n\n${ctx.customPromptHint}`)
|
|
910
|
+
if (ctx.task?.cursorRules?.trim()) parts.push(`\n## Task-Specific Cursor Rules\n\n${ctx.task.cursorRules}`)
|
|
911
|
+
if (ctx.task?.readmeMarkdown?.trim()) parts.push(`\n## Implementation Plan\n\n${ctx.task.readmeMarkdown}`)
|
|
912
|
+
if (ctx.task?.scoutReport?.trim()) parts.push(`\n## Scout Report (Codebase Analysis)\n\n${ctx.task.scoutReport}`)
|
|
913
|
+
if (ctx.task?.decomposition?.trim()) {
|
|
914
|
+
try {
|
|
915
|
+
const dec = JSON.parse(ctx.task.decomposition)
|
|
916
|
+
parts.push(`\n## Execution Plan\n\nExecution order: ${JSON.stringify(dec.executionOrder)}\nFile ownership: ${JSON.stringify(dec.fileOwnershipMap, null, 2)}`)
|
|
917
|
+
} catch { /* non-fatal */ }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return text({
|
|
921
|
+
role: effectiveRole,
|
|
922
|
+
taskKey: ctx.taskKey,
|
|
923
|
+
taskTitle: ctx.taskTitle,
|
|
924
|
+
systemPrompt: parts.join('\n\n'),
|
|
925
|
+
allowedTools: ctx.customAllowedTools || null,
|
|
926
|
+
claimedFiles: ctx.task?.claimedFiles || [],
|
|
927
|
+
warnings: ctx.warnings || [],
|
|
928
|
+
task: ctx.task,
|
|
929
|
+
project: ctx.project,
|
|
930
|
+
usage: 'Use systemPrompt as your Claude system prompt. If allowedTools is set, restrict your MCP tool calls to that list.',
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
// ── review_pr ────────────────────────────────────────────────────────────────
|
|
936
|
+
server.tool(
|
|
937
|
+
'review_pr',
|
|
938
|
+
`Fetch everything needed to do a thorough PR review: PR details, full code diff, CI status, and the task's implementation plan.
|
|
939
|
+
|
|
940
|
+
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.
|
|
941
|
+
|
|
942
|
+
MANDATORY: After calling this tool, you MUST:
|
|
943
|
+
1. Read every file in diff.files — check the patch hunk line by line
|
|
944
|
+
2. Compare each change against spec.implementationPlan — does the code implement what was planned?
|
|
945
|
+
3. Identify what is present, what is missing, what is wrong
|
|
946
|
+
4. Build analysisPoints (min 2) referencing specific filenames and function names
|
|
947
|
+
5. Only THEN call post_pr_review with your verdict and analysisPoints
|
|
948
|
+
|
|
949
|
+
Do NOT call post_pr_review immediately after this without analyzing the diff first.
|
|
950
|
+
Do NOT approve a PR without reading the patch hunks.
|
|
951
|
+
|
|
952
|
+
Use this when the developer asks "review PR", "check the PR for TASK-X", or "is this PR ready to merge".`,
|
|
953
|
+
{
|
|
954
|
+
taskId: z.string().describe("Task's MongoDB ObjectId (used to find the project + PR number)"),
|
|
955
|
+
},
|
|
956
|
+
async ({ taskId }) => {
|
|
957
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
958
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
959
|
+
const task = taskRes.data.task
|
|
960
|
+
|
|
961
|
+
const prNumber = task.github?.prNumber
|
|
962
|
+
if (!prNumber) return errorText('No PR linked to this task. The developer must raise_pr first.')
|
|
963
|
+
|
|
964
|
+
const projectId = task.project?._id || task.project
|
|
965
|
+
const res = await apiWithRetry(() => api.get(`/api/projects/${projectId}/github/pull-requests/${prNumber}`))
|
|
966
|
+
if (!res?.success) return errorText(res?.message || 'Could not fetch PR from GitHub')
|
|
967
|
+
|
|
968
|
+
const { pr, files, checks, task: linkedTask } = res.data
|
|
969
|
+
|
|
970
|
+
// Build a reviewer checklist from the implementation plan
|
|
971
|
+
const hasReadme = linkedTask?.readmeMarkdown?.trim()
|
|
972
|
+
const checklist = [
|
|
973
|
+
'[ ] Code matches the implementation plan (README)',
|
|
974
|
+
'[ ] All subtasks/acceptance criteria covered',
|
|
975
|
+
'[ ] No obvious bugs or edge cases missed',
|
|
976
|
+
'[ ] No hardcoded secrets or credentials',
|
|
977
|
+
'[ ] Error handling is in place where needed',
|
|
978
|
+
'[ ] No console.log / debug code left in',
|
|
979
|
+
checks?.anyFailed ? '[ ] ⚠️ CI checks are failing — investigate before approving' : '[ ] CI checks passing ✅',
|
|
980
|
+
pr.changesRequested > 0 ? `[ ] ⚠️ ${pr.changesRequested} reviewer(s) already requested changes` : null,
|
|
981
|
+
].filter(Boolean)
|
|
982
|
+
|
|
983
|
+
return text({
|
|
984
|
+
pr: {
|
|
985
|
+
number: pr.number,
|
|
986
|
+
title: pr.title,
|
|
987
|
+
author: pr.author,
|
|
988
|
+
headBranch: pr.headBranch,
|
|
989
|
+
baseBranch: pr.baseBranch,
|
|
990
|
+
state: pr.state,
|
|
991
|
+
approvals: pr.approvals,
|
|
992
|
+
changesRequested: pr.changesRequested,
|
|
993
|
+
mergeable: pr.mergeable,
|
|
994
|
+
mergeableState: pr.mergeableState,
|
|
995
|
+
url: pr.htmlUrl,
|
|
996
|
+
},
|
|
997
|
+
ci: checks || { runs: [], allPassed: null, note: 'Could not fetch CI checks' },
|
|
998
|
+
diff: {
|
|
999
|
+
totalFiles: files.length,
|
|
1000
|
+
totalAdditions: files.reduce((s, f) => s + f.additions, 0),
|
|
1001
|
+
totalDeletions: files.reduce((s, f) => s + f.deletions, 0),
|
|
1002
|
+
files: files.map(f => ({
|
|
1003
|
+
filename: f.filename,
|
|
1004
|
+
status: f.status,
|
|
1005
|
+
additions: f.additions,
|
|
1006
|
+
deletions: f.deletions,
|
|
1007
|
+
patch: f.patch || '(binary or no diff)',
|
|
1008
|
+
})),
|
|
1009
|
+
},
|
|
1010
|
+
spec: {
|
|
1011
|
+
taskKey: linkedTask?.key,
|
|
1012
|
+
taskTitle: linkedTask?.title,
|
|
1013
|
+
implementationPlan: hasReadme ? linkedTask.readmeMarkdown : '⚠️ No implementation plan — review code against description only.',
|
|
1014
|
+
description: linkedTask?.description || null,
|
|
1015
|
+
},
|
|
1016
|
+
reviewChecklist: checklist,
|
|
1017
|
+
nextStep: pr.approvals > 0 && !pr.changesRequested
|
|
1018
|
+
? `PR has ${pr.approvals} approval(s) and no changes requested. Call merge_pr with taskId="${taskId}" when ready.`
|
|
1019
|
+
: `Review the diff above against the spec, then call post_pr_review with taskId="${taskId}" to post your verdict.`,
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
// ── post_pr_review ────────────────────────────────────────────────────────────
|
|
1025
|
+
server.tool(
|
|
1026
|
+
'post_pr_review',
|
|
1027
|
+
`Post a GitHub PR review after analyzing the code diff from review_pr.
|
|
1028
|
+
|
|
1029
|
+
Supports three actions:
|
|
1030
|
+
- APPROVE — Approves the PR. Notifies the developer they can merge.
|
|
1031
|
+
- REQUEST_CHANGES — Blocks merge. Developer gets notified. Task board updated.
|
|
1032
|
+
- COMMENT — Leaves a comment without approving or blocking.
|
|
1033
|
+
|
|
1034
|
+
REQUIRED WORKFLOW — you MUST call review_pr first and read the diff before calling this.
|
|
1035
|
+
You MUST populate analysisPoints with specific findings from the diff — what you checked,
|
|
1036
|
+
what matched the spec, what is missing or broken. Generic analysis like "code looks good"
|
|
1037
|
+
will be rejected. Be file-specific: reference filenames, function names, line numbers.
|
|
1038
|
+
|
|
1039
|
+
Human approval gate: confirmed=false shows preview, confirmed=true posts to GitHub.
|
|
1040
|
+
|
|
1041
|
+
Returns reviewId — save it and pass it to merge_pr to prove semantic review happened.`,
|
|
1042
|
+
{
|
|
1043
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1044
|
+
event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe('Review action'),
|
|
1045
|
+
body: z.string().describe('Review summary posted to GitHub — be specific about files, functions, issues'),
|
|
1046
|
+
analysisPoints: z.array(z.string()).min(2).describe(
|
|
1047
|
+
'Structured findings from your diff analysis. Each entry must reference a specific file, function, or line. ' +
|
|
1048
|
+
'Examples: ["calculator.js: add/subtract/multiply all present and correct", ' +
|
|
1049
|
+
'"divide() function missing — spec requires divide-by-zero handling", ' +
|
|
1050
|
+
'"No input validation on any function — null/string inputs silently coerce"]. ' +
|
|
1051
|
+
'Minimum 2 points required. Generic entries like "code looks fine" are rejected.'
|
|
1052
|
+
),
|
|
1053
|
+
confirmed: z.boolean().optional().default(false).describe('Set true after the human has read and approved the review text'),
|
|
1054
|
+
},
|
|
1055
|
+
async ({ taskId, event, body: reviewBody, analysisPoints, confirmed = false }) => {
|
|
1056
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
1057
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
1058
|
+
const task = taskRes.data.task
|
|
1059
|
+
|
|
1060
|
+
const prNumber = task.github?.prNumber
|
|
1061
|
+
const projectId = task.project?._id || task.project
|
|
1062
|
+
if (!prNumber) return errorText('No PR linked to this task. Call raise_pr first.')
|
|
1063
|
+
|
|
1064
|
+
// Enforce that analysisPoints are specific — reject obviously generic ones
|
|
1065
|
+
const genericPhrases = ['looks good', 'lgtm', 'code is fine', 'no issues', 'all good', 'seems correct']
|
|
1066
|
+
const tooGeneric = analysisPoints.filter(p =>
|
|
1067
|
+
p.trim().length < 20 || genericPhrases.some(g => p.toLowerCase().includes(g))
|
|
1068
|
+
)
|
|
1069
|
+
if (tooGeneric.length > 0) {
|
|
1070
|
+
return text({
|
|
1071
|
+
blocked: true,
|
|
1072
|
+
reason: 'analysisPoints must be file-specific and reference actual diff content.',
|
|
1073
|
+
rejected: tooGeneric,
|
|
1074
|
+
instruction: 'Call review_pr first to get the diff, then re-examine each file and describe specific findings.',
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Preview — human must confirm
|
|
1079
|
+
if (!confirmed) {
|
|
1080
|
+
return text({
|
|
1081
|
+
preview: {
|
|
1082
|
+
action: event,
|
|
1083
|
+
prNumber,
|
|
1084
|
+
taskKey: task.key,
|
|
1085
|
+
taskTitle: task.title,
|
|
1086
|
+
reviewBody,
|
|
1087
|
+
analysisPoints,
|
|
1088
|
+
},
|
|
1089
|
+
warning: event === 'APPROVE'
|
|
1090
|
+
? '✅ This will APPROVE the PR on GitHub and notify the developer they can merge.'
|
|
1091
|
+
: event === 'REQUEST_CHANGES'
|
|
1092
|
+
? '⚠️ This will REQUEST CHANGES — the developer will be blocked from merging until they fix the issues.'
|
|
1093
|
+
: 'ℹ️ This will post a comment on the PR without approving or blocking.',
|
|
1094
|
+
requiresConfirmation: true,
|
|
1095
|
+
message: 'Show the review body and analysis points to the user and ask them to confirm. Then call post_pr_review again with confirmed=true.',
|
|
1096
|
+
})
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const res = await api.post(
|
|
1100
|
+
`/api/projects/${projectId}/github/pull-requests/${prNumber}/reviews`,
|
|
1101
|
+
{ event, body: reviewBody }
|
|
1102
|
+
)
|
|
1103
|
+
if (!res?.success) return errorText(res?.message || 'Could not post review')
|
|
1104
|
+
|
|
1105
|
+
const reviewId = res.data?.reviewId
|
|
1106
|
+
|
|
1107
|
+
return text({
|
|
1108
|
+
posted: true,
|
|
1109
|
+
event,
|
|
1110
|
+
prNumber,
|
|
1111
|
+
reviewId,
|
|
1112
|
+
taskKey: task.key,
|
|
1113
|
+
analysisPoints,
|
|
1114
|
+
message: event === 'APPROVE'
|
|
1115
|
+
? `✅ PR #${prNumber} approved. Developer has been notified and can now merge.`
|
|
1116
|
+
: event === 'REQUEST_CHANGES'
|
|
1117
|
+
? `⚠️ Changes requested on PR #${prNumber}. Developer has been notified. Task board updated.`
|
|
1118
|
+
: `💬 Comment posted on PR #${prNumber}.`,
|
|
1119
|
+
nextStep: event === 'APPROVE'
|
|
1120
|
+
? `Call merge_pr with taskId="${taskId}" and reviewId="${reviewId}" to merge.`
|
|
1121
|
+
: event === 'REQUEST_CHANGES'
|
|
1122
|
+
? `Wait for the developer to push fixes. They'll call fix_pr_feedback, then re-push. You'll be notified.`
|
|
1123
|
+
: null,
|
|
1124
|
+
})
|
|
1125
|
+
}
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
// ── merge_pr ─────────────────────────────────────────────────────────────────
|
|
1129
|
+
server.tool(
|
|
1130
|
+
'merge_pr',
|
|
1131
|
+
`Merge a pull request after semantic review and human approval.
|
|
1132
|
+
|
|
1133
|
+
REQUIRED WORKFLOW:
|
|
1134
|
+
1. Call review_pr — read the diff and spec
|
|
1135
|
+
2. Call post_pr_review — post your verdict with specific analysisPoints, get back reviewId
|
|
1136
|
+
3. Call merge_pr — pass reviewId to prove semantic review happened
|
|
1137
|
+
|
|
1138
|
+
If the PR already has GitHub approvals from before this session, reviewId is not required.
|
|
1139
|
+
|
|
1140
|
+
Safety checks (pre-merge):
|
|
1141
|
+
- Semantic review: reviewId required if PR has no existing GitHub approvals
|
|
1142
|
+
- PR must be open and not already merged
|
|
1143
|
+
- No pending change requests
|
|
1144
|
+
- CI checks must be passing (pass skipChecks=true to override)
|
|
1145
|
+
|
|
1146
|
+
Human approval gate:
|
|
1147
|
+
- confirmed=false (default): shows all checks and asks for confirmation
|
|
1148
|
+
- confirmed=true: executes the merge
|
|
1149
|
+
|
|
1150
|
+
The task moves to Done automatically via the GitHub webhook.`,
|
|
1151
|
+
{
|
|
1152
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1153
|
+
confirmed: z.boolean().optional().default(false).describe('Set true only after the human has reviewed the safety checks and approved the merge'),
|
|
1154
|
+
mergeMethod: z.enum(['squash', 'merge', 'rebase']).optional().default('squash').describe('Merge strategy (default: squash)'),
|
|
1155
|
+
commitTitle: z.string().optional().describe('Override the merge commit title. Defaults to PR title (#number).'),
|
|
1156
|
+
skipChecks: z.boolean().optional().default(false).describe('Skip CI check gate — use only when checks are informational or flaky'),
|
|
1157
|
+
reviewId: z.number().optional().describe('GitHub review ID returned by post_pr_review. Required when PR has no existing approvals — proves semantic review happened in this session.'),
|
|
1158
|
+
},
|
|
1159
|
+
async ({ taskId, confirmed = false, mergeMethod = 'squash', commitTitle, skipChecks = false, reviewId }) => {
|
|
1160
|
+
const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
|
|
1161
|
+
if (!taskRes?.success) return errorText('Task not found')
|
|
1162
|
+
const task = taskRes.data.task
|
|
1163
|
+
|
|
1164
|
+
const prNumber = task.github?.prNumber
|
|
1165
|
+
const projectId = task.project?._id || task.project
|
|
1166
|
+
if (!prNumber) return errorText(`No PR linked to task ${task.key}. The developer must raise_pr first.`)
|
|
1167
|
+
|
|
1168
|
+
// Fetch current PR state + checks for the preview
|
|
1169
|
+
const prContextRes = await api.get(`/api/projects/${projectId}/github/pull-requests/${prNumber}`)
|
|
1170
|
+
if (!prContextRes?.success) return errorText('Could not fetch PR details from GitHub')
|
|
1171
|
+
const { pr, checks } = prContextRes.data
|
|
1172
|
+
|
|
1173
|
+
if (pr.merged) {
|
|
1174
|
+
return text({ alreadyMerged: true, message: `PR #${prNumber} is already merged. Task should be in Done.` })
|
|
1175
|
+
}
|
|
1176
|
+
if (pr.state !== 'open') {
|
|
1177
|
+
return text({ blocked: true, reason: `PR #${prNumber} is ${pr.state} — cannot merge a closed PR.` })
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ── Semantic review gate ──────────────────────────────────────────────────
|
|
1181
|
+
// If the PR has no GitHub approvals from any session, a reviewId from
|
|
1182
|
+
// post_pr_review in this session is mandatory. This ensures code was
|
|
1183
|
+
// actually read and analyzed before merge, not just rubber-stamped.
|
|
1184
|
+
const hasExistingApproval = pr.approvals > 0
|
|
1185
|
+
const hasSessionReview = !!reviewId
|
|
1186
|
+
if (!hasExistingApproval && !hasSessionReview) {
|
|
1187
|
+
return text({
|
|
1188
|
+
blocked: true,
|
|
1189
|
+
reason: 'No semantic review has been performed on this PR.',
|
|
1190
|
+
required: [
|
|
1191
|
+
`1. Call review_pr with taskId="${taskId}" to read the diff and spec`,
|
|
1192
|
+
`2. Analyze the diff — check every file against the implementation plan`,
|
|
1193
|
+
`3. Call post_pr_review with taskId="${taskId}", your verdict, and specific analysisPoints`,
|
|
1194
|
+
`4. Pass the returned reviewId here to prove the review happened`,
|
|
1195
|
+
],
|
|
1196
|
+
note: 'If this PR was already approved by a reviewer on GitHub, merge_pr will allow it through automatically.',
|
|
1197
|
+
})
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Build safety check summary
|
|
1201
|
+
const safetyChecks = [
|
|
1202
|
+
{ check: 'Semantic review performed', pass: hasExistingApproval || hasSessionReview,
|
|
1203
|
+
note: hasSessionReview ? `reviewId ${reviewId} from this session` : hasExistingApproval ? `${pr.approvals} existing GitHub approval(s)` : 'Missing — call review_pr then post_pr_review' },
|
|
1204
|
+
{ check: 'PR is open', pass: pr.state === 'open' },
|
|
1205
|
+
{ check: 'Not already merged', pass: !pr.merged },
|
|
1206
|
+
{ check: 'No change requests', pass: pr.changesRequested === 0,
|
|
1207
|
+
note: pr.changesRequested > 0 ? `${pr.changesRequested} reviewer(s) requested changes` : null },
|
|
1208
|
+
{ check: `CI checks (${checks?.total ?? 0} runs)`,
|
|
1209
|
+
pass: skipChecks ? null : (checks?.allPassed ?? null),
|
|
1210
|
+
note: skipChecks ? 'Skipped by request' : checks?.anyFailed ? 'Some checks failed' : checks?.allPassed ? 'All passing' : 'No checks found' },
|
|
1211
|
+
{ check: 'Has at least one approval', pass: pr.approvals > 0,
|
|
1212
|
+
note: pr.approvals === 0 ? 'No approvals yet — consider running post_pr_review first' : `${pr.approvals} approval(s)` },
|
|
1213
|
+
]
|
|
1214
|
+
|
|
1215
|
+
const hardBlocks = safetyChecks.filter(c => c.pass === false && c.check !== `CI checks (${checks?.total ?? 0} runs)`)
|
|
1216
|
+
const ciBlocked = !skipChecks && checks?.anyFailed
|
|
1217
|
+
|
|
1218
|
+
if (!confirmed) {
|
|
1219
|
+
return text({
|
|
1220
|
+
pr: {
|
|
1221
|
+
number: pr.number,
|
|
1222
|
+
title: pr.title,
|
|
1223
|
+
author: pr.author,
|
|
1224
|
+
headBranch: pr.headBranch,
|
|
1225
|
+
approvals: pr.approvals,
|
|
1226
|
+
url: pr.htmlUrl,
|
|
1227
|
+
},
|
|
1228
|
+
safetyChecks,
|
|
1229
|
+
mergeStrategy: mergeMethod,
|
|
1230
|
+
blocked: hardBlocks.length > 0 || ciBlocked,
|
|
1231
|
+
blockers: [
|
|
1232
|
+
...hardBlocks.map(c => c.note || c.check),
|
|
1233
|
+
ciBlocked ? 'CI checks are failing (pass skipChecks=true to override)' : null,
|
|
1234
|
+
].filter(Boolean),
|
|
1235
|
+
requiresConfirmation: hardBlocks.length === 0 && !ciBlocked,
|
|
1236
|
+
message: hardBlocks.length > 0 || ciBlocked
|
|
1237
|
+
? `⚠️ Merge blocked. Resolve the issues above before merging.`
|
|
1238
|
+
: `Show the safety checks above to the user and ask them to confirm the merge. Then call merge_pr again with confirmed=true.`,
|
|
1239
|
+
})
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Hard-block even on confirmed=true if there are unresolved change requests
|
|
1243
|
+
if (pr.changesRequested > 0) {
|
|
1244
|
+
return text({
|
|
1245
|
+
blocked: true,
|
|
1246
|
+
reason: `Cannot merge: ${pr.changesRequested} reviewer(s) have requested changes. They must approve before merging.`,
|
|
1247
|
+
})
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Execute merge
|
|
1251
|
+
const mergeRes = await api.put(
|
|
1252
|
+
`/api/projects/${projectId}/github/pull-requests/${prNumber}/merge`,
|
|
1253
|
+
{ mergeMethod, commitTitle: commitTitle || undefined, skipChecks }
|
|
1254
|
+
)
|
|
1255
|
+
if (!mergeRes?.success) return errorText(mergeRes?.message || 'Merge failed')
|
|
1256
|
+
|
|
1257
|
+
if (mergeRes.data?.alreadyMerged) {
|
|
1258
|
+
return text({ merged: true, message: 'PR was already merged.' })
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return text({
|
|
1262
|
+
merged: true,
|
|
1263
|
+
sha: mergeRes.data?.sha,
|
|
1264
|
+
prNumber,
|
|
1265
|
+
taskKey: task.key,
|
|
1266
|
+
mergeMethod,
|
|
1267
|
+
message: `✅ PR #${prNumber} merged via ${mergeMethod}. Task "${task.key}" will move to Done automatically.`,
|
|
1268
|
+
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.`,
|
|
1269
|
+
})
|
|
1270
|
+
}
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
// ── resume_task ───────────────────────────────────────────────────────────────
|
|
1274
|
+
server.tool(
|
|
1275
|
+
'resume_task',
|
|
1276
|
+
`One-click safe resume for a task you previously worked on.
|
|
1277
|
+
|
|
1278
|
+
Combines: context fetch + dirty-tree check + optional auto-stash + checkout + pull + readiness summary.
|
|
1279
|
+
|
|
1280
|
+
Use this when the developer says "resume task", "get back to task X", "continue my work on", or
|
|
1281
|
+
"switch back to TASK-KEY". Prefer this over manually running git commands.
|
|
1282
|
+
|
|
1283
|
+
Flow:
|
|
1284
|
+
1. confirmed=false (default) → shows full context: park note, last commits, working-tree state, what will happen
|
|
1285
|
+
2. confirmed=true → executes: stash (if needed + autoStash=true), checkout, pull, restore cursor rules, return readiness summary`,
|
|
1286
|
+
{
|
|
1287
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1288
|
+
confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the context to execute the resume'),
|
|
1289
|
+
repoPath: z.string().optional().describe('Absolute path to the local git repo. Defaults to MCP process working directory.'),
|
|
1290
|
+
autoStash: z.boolean().optional().default(false).describe('If working tree is dirty, auto-stash before switching. Labelled with task key.'),
|
|
1291
|
+
agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
|
|
1292
|
+
.describe('Set the agent role for this resumed session. Role-specific constraints are injected into cursor rules.'),
|
|
1293
|
+
},
|
|
1294
|
+
async ({ taskId, confirmed = false, repoPath, autoStash = false, agentRole }) => {
|
|
1295
|
+
// ── Fetch task ────────────────────────────────────────────────────────
|
|
1296
|
+
let taskRes
|
|
1297
|
+
try { taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`)) }
|
|
1298
|
+
catch (e) { return errorText(`Could not fetch task: ${e.message}`) }
|
|
1299
|
+
if (!taskRes?.success) return errorText(taskRes?.message || 'Task not found')
|
|
1300
|
+
const task = taskRes.data.task
|
|
1301
|
+
const branch = task?.github?.headBranch || null
|
|
1302
|
+
|
|
1303
|
+
// ── Fetch recent comments ─────────────────────────────────────────────
|
|
1304
|
+
let recentComments = []
|
|
1305
|
+
try {
|
|
1306
|
+
const commentsRes = await api.get(`/api/tasks/${taskId}/comments`)
|
|
1307
|
+
recentComments = (commentsRes?.data?.comments || []).slice(-5).map(c => ({
|
|
1308
|
+
author: c.author?.name || c.author?.email || 'unknown',
|
|
1309
|
+
body: c.body?.slice(0, 300),
|
|
1310
|
+
at: c.createdAt,
|
|
1311
|
+
}))
|
|
1312
|
+
} catch { /* non-fatal */ }
|
|
1313
|
+
|
|
1314
|
+
// ── Fetch recent commits on branch ────────────────────────────────────
|
|
1315
|
+
let recentCommits = []
|
|
1316
|
+
if (branch) {
|
|
1317
|
+
try {
|
|
1318
|
+
const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=5&branch=${branch}`)
|
|
1319
|
+
recentCommits = (commitsRes?.data?.commits || []).map(c => ({
|
|
1320
|
+
sha: c.sha?.slice(0, 7),
|
|
1321
|
+
message: c.commit?.message?.split('\n')[0],
|
|
1322
|
+
author: c.commit?.author?.name,
|
|
1323
|
+
date: c.commit?.author?.date,
|
|
1324
|
+
}))
|
|
1325
|
+
} catch { /* non-fatal */ }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// ── Inspect local repo state ──────────────────────────────────────────
|
|
1329
|
+
const repoRoot = findRepoRoot(repoPath || process.cwd())
|
|
1330
|
+
let localState = 'clean'
|
|
1331
|
+
let modified = []
|
|
1332
|
+
let currentBranch = null
|
|
1333
|
+
let alreadyOnBranch = false
|
|
1334
|
+
|
|
1335
|
+
if (repoRoot) {
|
|
1336
|
+
try {
|
|
1337
|
+
const porcelain = runGit('status --porcelain=v1', repoRoot)
|
|
1338
|
+
const parsed = parseGitStatus(porcelain)
|
|
1339
|
+
localState = parsed.localState
|
|
1340
|
+
modified = parsed.modified
|
|
1341
|
+
currentBranch = runGit('branch --show-current', repoRoot).trim()
|
|
1342
|
+
alreadyOnBranch = branch && currentBranch === branch
|
|
1343
|
+
} catch { /* non-fatal */ }
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ── Preview ───────────────────────────────────────────────────────────
|
|
1347
|
+
if (!confirmed) {
|
|
1348
|
+
const willStash = localState === 'modified' && !alreadyOnBranch
|
|
1349
|
+
const stashBlocks = willStash && !autoStash
|
|
1350
|
+
|
|
1351
|
+
return text({
|
|
1352
|
+
task: {
|
|
1353
|
+
key: task.key,
|
|
1354
|
+
title: task.title,
|
|
1355
|
+
column: task.column,
|
|
1356
|
+
priority: task.priority,
|
|
1357
|
+
branch: branch || '(no branch)',
|
|
1358
|
+
},
|
|
1359
|
+
parkNote: task.parkNote || null,
|
|
1360
|
+
recentCommits: recentCommits.length ? recentCommits : ['No remote commits found'],
|
|
1361
|
+
recentComments,
|
|
1362
|
+
localRepo: repoRoot ? {
|
|
1363
|
+
root: repoRoot,
|
|
1364
|
+
currentBranch,
|
|
1365
|
+
alreadyOnBranch,
|
|
1366
|
+
workingTree: localState,
|
|
1367
|
+
modifiedFiles: modified.length ? modified : null,
|
|
1368
|
+
} : { found: false, note: 'No git repo found at ' + (repoPath || process.cwd()) },
|
|
1369
|
+
willDo: branch && !alreadyOnBranch ? [
|
|
1370
|
+
willStash && autoStash ? `git stash push -m "auto-stash(${task.key?.toLowerCase()}): changes on ${currentBranch} before resuming"` : null,
|
|
1371
|
+
`git fetch origin`,
|
|
1372
|
+
`git checkout ${branch}`,
|
|
1373
|
+
`git pull origin ${branch}`,
|
|
1374
|
+
task?.cursorRules?.trim() ? `Restore .cursor/rules/${task.key?.toLowerCase()}.mdc` : null,
|
|
1375
|
+
].filter(Boolean) : alreadyOnBranch ? [`git pull origin ${branch} (already on branch)`] : ['No branch to switch to'],
|
|
1376
|
+
blocked: stashBlocks ? {
|
|
1377
|
+
reason: `Working tree is dirty (${modified.length} file(s) modified) and you are not on the task branch.`,
|
|
1378
|
+
choices: {
|
|
1379
|
+
autoStash: `Call resume_task again with autoStash=true — MCP stashes automatically`,
|
|
1380
|
+
manual: `git stash push -m "wip: before resuming ${task.key}"`,
|
|
1381
|
+
cancel: `Commit or discard your current changes first`,
|
|
1382
|
+
},
|
|
1383
|
+
} : null,
|
|
1384
|
+
requiresConfirmation: !stashBlocks,
|
|
1385
|
+
message: stashBlocks
|
|
1386
|
+
? `Working tree is dirty — resolve or pass autoStash=true, then call resume_task with confirmed=true.`
|
|
1387
|
+
: `Review the context above, then call resume_task again with confirmed=true to switch branches and resume.`,
|
|
1388
|
+
})
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// ── Execute ───────────────────────────────────────────────────────────
|
|
1392
|
+
if (!branch) {
|
|
1393
|
+
return text({
|
|
1394
|
+
resumed: false,
|
|
1395
|
+
reason: 'No branch linked to this task yet.',
|
|
1396
|
+
nextStep: `Call create_branch with taskId="${taskId}" to create one.`,
|
|
1397
|
+
})
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Handle dirty tree
|
|
1401
|
+
if (localState === 'modified' && !alreadyOnBranch) {
|
|
1402
|
+
if (autoStash) {
|
|
1403
|
+
try {
|
|
1404
|
+
const stashLabel = `auto-stash(${task.key?.toLowerCase()}): changes on ${currentBranch} before resuming ${branch}`
|
|
1405
|
+
runGit(`stash push -m "${stashLabel}"`, repoRoot)
|
|
1406
|
+
} catch (e) {
|
|
1407
|
+
return text({
|
|
1408
|
+
resumed: false,
|
|
1409
|
+
reason: `Auto-stash failed: ${e.message.split('\n')[0]}`,
|
|
1410
|
+
nextStep: `Manually run: git stash push -m "wip" then retry.`,
|
|
1411
|
+
})
|
|
1412
|
+
}
|
|
1413
|
+
} else {
|
|
1414
|
+
return text({
|
|
1415
|
+
blocked: true,
|
|
1416
|
+
reason: `Working tree has ${modified.length} modified file(s). Cannot switch branches safely.`,
|
|
1417
|
+
choices: {
|
|
1418
|
+
autoStash: `Call resume_task again with confirmed=true and autoStash=true`,
|
|
1419
|
+
manual: `git stash push -m "wip: before resuming ${task.key}"`,
|
|
1420
|
+
cancel: `Commit or discard your changes first`,
|
|
1421
|
+
},
|
|
1422
|
+
})
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Switch branch + pull
|
|
1427
|
+
let gitResult = null
|
|
1428
|
+
if (repoRoot) {
|
|
1429
|
+
if (alreadyOnBranch) {
|
|
1430
|
+
try {
|
|
1431
|
+
const pullOut = runGit(`pull origin ${branch}`, repoRoot)
|
|
1432
|
+
gitResult = { switched: false, alreadyOnBranch: true, pulled: true, pullOutput: pullOut.slice(0, 200) }
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
gitResult = { switched: false, alreadyOnBranch: true, pulled: false, error: e.message.split('\n')[0] }
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
try {
|
|
1438
|
+
runGit('fetch origin', repoRoot)
|
|
1439
|
+
runGit(`checkout ${branch}`, repoRoot)
|
|
1440
|
+
const pullOut = runGit(`pull origin ${branch}`, repoRoot)
|
|
1441
|
+
gitResult = { switched: true, branch, pulled: true, pullOutput: pullOut.slice(0, 200) }
|
|
1442
|
+
} catch (e) {
|
|
1443
|
+
gitResult = {
|
|
1444
|
+
switched: false,
|
|
1445
|
+
error: e.message.split('\n')[0],
|
|
1446
|
+
manualSteps: [`git fetch origin`, `git checkout ${branch}`, `git pull origin ${branch}`],
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Restore cursor rules (with role injection if set)
|
|
1453
|
+
let cursorRulesFile = null
|
|
1454
|
+
const hasCursorRulesResume = task?.cursorRules?.trim()
|
|
1455
|
+
if ((hasCursorRulesResume || agentRole) && repoRoot) {
|
|
1456
|
+
cursorRulesFile = writeCursorRulesFile(
|
|
1457
|
+
task.key,
|
|
1458
|
+
hasCursorRulesResume || '(No task-specific rules — follow role constraints above.)',
|
|
1459
|
+
repoPath,
|
|
1460
|
+
agentRole || null
|
|
1461
|
+
)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Persist agentRole to server if set
|
|
1465
|
+
if (agentRole) {
|
|
1466
|
+
api.patch(`/api/tasks/${taskId}`, { agentRole }).catch(() => {/* non-fatal */})
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Last commit metadata
|
|
1470
|
+
const lastCommit = getLastCommitMeta(repoRoot)
|
|
1471
|
+
|
|
1472
|
+
return text({
|
|
1473
|
+
resumed: true,
|
|
1474
|
+
task: { key: task.key, title: task.title, column: task.column, agentRole: agentRole || null },
|
|
1475
|
+
git: gitResult || { switched: false, reason: 'No repo found' },
|
|
1476
|
+
autoStashed: autoStash && localState === 'modified' && !alreadyOnBranch
|
|
1477
|
+
? `Changes stashed before switch. Run: git stash list to see them.`
|
|
1478
|
+
: null,
|
|
1479
|
+
lastCommit: lastCommit || null,
|
|
1480
|
+
parkNote: task.parkNote || null,
|
|
1481
|
+
cursorRules: cursorRulesFile
|
|
1482
|
+
? { restored: true, path: cursorRulesFile, agentRole: agentRole || null }
|
|
1483
|
+
: { restored: false },
|
|
1484
|
+
message: gitResult?.switched || gitResult?.alreadyOnBranch
|
|
1485
|
+
? `You are on branch "${branch}". Ready to resume coding${agentRole ? ` as ${agentRole}` : ''}.`
|
|
1486
|
+
: `Branch switch failed — see git.manualSteps.`,
|
|
1487
|
+
nextStep: agentRole === 'builder'
|
|
1488
|
+
? `BUILDER role active. Call claim_files to lock your files before editing.`
|
|
1489
|
+
: agentRole === 'scout'
|
|
1490
|
+
? `SCOUT role active. Read-only mode — map the codebase and save findings with update_task(scoutReport=...).`
|
|
1491
|
+
: agentRole === 'coordinator'
|
|
1492
|
+
? `COORDINATOR role active. Call decompose_task to plan parallel workstreams.`
|
|
1493
|
+
: agentRole === 'reviewer'
|
|
1494
|
+
? `REVIEWER role active. Call review_pr to start the review chain.`
|
|
1495
|
+
: task.column === 'in_review'
|
|
1496
|
+
? `Task is in review. Check if PR feedback needs addressing — call fix_pr_feedback if needed.`
|
|
1497
|
+
: task.parkNote?.remaining
|
|
1498
|
+
? `Remaining: ${task.parkNote.remaining}`
|
|
1499
|
+
: `Continue coding on "${branch}".`,
|
|
530
1500
|
})
|
|
531
1501
|
}
|
|
532
1502
|
)
|
|
@@ -542,9 +1512,10 @@ function formatActivityLine(a) {
|
|
|
542
1512
|
case 'task.moved': return `Moved from ${m.from} → ${m.to}`
|
|
543
1513
|
case 'task.branch_linked': return `Branch linked: ${m.headBranch}`
|
|
544
1514
|
case 'comment.created': return 'Comment posted'
|
|
545
|
-
case 'approval.submitted':
|
|
546
|
-
case 'approval.approved':
|
|
547
|
-
case 'approval.rejected':
|
|
1515
|
+
case 'approval.submitted': return `Sent for approval → ${m.reviewerName || 'reviewer'}`
|
|
1516
|
+
case 'approval.approved': return `Plan approved${m.note ? ': ' + m.note : ''}`
|
|
1517
|
+
case 'approval.rejected': return `Plan rejected${m.note ? ': ' + m.note : ''}`
|
|
1518
|
+
case 'approval.ai_reviewed': return `AI review submitted by ${m.agentName || 'AI'} — ${m.recommendation || 'needs_work'}`
|
|
548
1519
|
case 'task.merged_from_github': return `PR #${m.prNumber} merged → done`
|
|
549
1520
|
case 'task.pr_opened_to_review': return `PR #${m.prNumber} opened — moved to in_review`
|
|
550
1521
|
case 'task.pr_linked_in_review': return `PR #${m.prNumber} linked (already in review)`
|
|
@@ -1023,8 +1994,10 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1023
1994
|
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
1024
1995
|
confirmed: z.boolean().optional().default(false).describe('Set true after reading the plan to move the task to in_progress'),
|
|
1025
1996
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to write cursor rules file.'),
|
|
1997
|
+
agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
|
|
1998
|
+
.describe('Set the agent role for this task session. Role-specific behavioral constraints are injected into cursor rules. builder=implements code, scout=reads/analyzes only, reviewer=reviews PRs only, coordinator=decomposes work.'),
|
|
1026
1999
|
},
|
|
1027
|
-
async ({ taskId, confirmed = false, repoPath }) => {
|
|
2000
|
+
async ({ taskId, confirmed = false, repoPath, agentRole }) => {
|
|
1028
2001
|
const taskRes = await api.get(`/api/tasks/${taskId}`)
|
|
1029
2002
|
if (!taskRes?.success) return errorText('Task not found')
|
|
1030
2003
|
const task = taskRes.data.task
|
|
@@ -1053,8 +2026,9 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1053
2026
|
const subtasksTotal = subtasks.length
|
|
1054
2027
|
|
|
1055
2028
|
// ── Preview: show the full plan before touching anything ──
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
2029
|
+
const pendingApv = (task.approvals || []).find(a => a.state === 'pending') || null
|
|
2030
|
+
const hasApprovedApv = (task.approvals || []).some(a => a.state === 'approved')
|
|
2031
|
+
const approvalBlocks = ['backlog', 'todo'].includes(task.column) && !hasApprovedApv
|
|
1058
2032
|
|
|
1059
2033
|
// Build the next-step roadmap so developer knows exactly what comes after reading the plan
|
|
1060
2034
|
let workflowRoadmap
|
|
@@ -1087,35 +2061,87 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1087
2061
|
}
|
|
1088
2062
|
|
|
1089
2063
|
// ── Simultaneous work lock ──────────────────────────────────────────────────
|
|
1090
|
-
// If the task is already in_progress with a branch, warn before proceeding.
|
|
1091
|
-
// This prevents two developers from unknowingly working on the same task.
|
|
1092
2064
|
const alreadyActive = task.column === 'in_progress' && !!task.github?.headBranch
|
|
1093
|
-
const activeAssignees = (task.assignees || []).map(a => a.name || a.email || a.toString())
|
|
1094
2065
|
|
|
1095
2066
|
if (!confirmed && alreadyActive) {
|
|
2067
|
+
// Determine the active owner precisely:
|
|
2068
|
+
// currentOwner = whoever last unparked (actively working now)
|
|
2069
|
+
// parkedBy = whoever parked (task is idle, waiting for pickup)
|
|
2070
|
+
// fallback = assignees list
|
|
2071
|
+
const currentOwner = task.parkNote?.currentOwner
|
|
2072
|
+
const parkedBy = task.parkNote?.parkedBy
|
|
2073
|
+
const isParked = !!task.parkNote?.parkedAt
|
|
2074
|
+
|
|
2075
|
+
const activeOwnerName = currentOwner
|
|
2076
|
+
? (currentOwner.name || currentOwner.email || currentOwner.toString())
|
|
2077
|
+
: null
|
|
2078
|
+
const parkedByName = parkedBy
|
|
2079
|
+
? (parkedBy.name || parkedBy.email || parkedBy.toString())
|
|
2080
|
+
: null
|
|
2081
|
+
const assigneeNames = (task.assignees || []).map(a => a.name || a.email || a.toString())
|
|
2082
|
+
|
|
2083
|
+
// Who is actively coding right now?
|
|
2084
|
+
let activeWorker, situation, advice
|
|
2085
|
+
if (isParked) {
|
|
2086
|
+
activeWorker = null
|
|
2087
|
+
situation = `Task is parked by ${parkedByName || 'a developer'} — no one is actively coding. Safe to pick up via unpark_task.`
|
|
2088
|
+
advice = `Call unpark_task with taskId="${taskId}" to take ownership and continue from where ${parkedByName || 'they'} left off.`
|
|
2089
|
+
} else if (activeOwnerName) {
|
|
2090
|
+
activeWorker = activeOwnerName
|
|
2091
|
+
situation = `${activeOwnerName} is actively working on this task right now.`
|
|
2092
|
+
advice = `Contact ${activeOwnerName} directly before taking over. Do NOT push to branch "${task.github.headBranch}" — it will cause a conflict.`
|
|
2093
|
+
} else if (assigneeNames.length) {
|
|
2094
|
+
activeWorker = assigneeNames.join(', ')
|
|
2095
|
+
situation = `${assigneeNames.join(' and ')} ${assigneeNames.length > 1 ? 'are' : 'is'} assigned to this task and it is in progress.`
|
|
2096
|
+
advice = `Check with ${assigneeNames.join(', ')} before taking over.`
|
|
2097
|
+
} else {
|
|
2098
|
+
activeWorker = null
|
|
2099
|
+
situation = `Task is in progress with no clear owner.`
|
|
2100
|
+
advice = `Check the branch "${task.github.headBranch}" before starting fresh.`
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// Notify all project members that someone attempted to take an active task
|
|
2104
|
+
try {
|
|
2105
|
+
const projectRes = await api.get(`/api/projects/${task.project}`)
|
|
2106
|
+
const members = projectRes?.data?.project?.members || []
|
|
2107
|
+
const meRes = await api.get('/api/auth/me')
|
|
2108
|
+
const meName = meRes?.data?.user?.name || meRes?.data?.name || 'Someone'
|
|
2109
|
+
const meId = meRes?.data?.user?._id || meRes?.data?._id || ''
|
|
2110
|
+
for (const memberId of members) {
|
|
2111
|
+
const id = memberId._id || memberId.toString()
|
|
2112
|
+
if (id !== meId) {
|
|
2113
|
+
await api.post('/api/notifications/task-event', {
|
|
2114
|
+
recipient: id,
|
|
2115
|
+
type: 'task_assigned',
|
|
2116
|
+
title: `👀 ${meName} tried to start ${task.key}`,
|
|
2117
|
+
body: `${meName} attempted to kick off "${task.title}" which is already active${activeOwnerName ? ` (owned by ${activeOwnerName})` : ''}.`,
|
|
2118
|
+
link: `/projects/${task.project}/tasks/${taskId}`,
|
|
2119
|
+
taskId,
|
|
2120
|
+
}).catch(() => {/* non-fatal */})
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
} catch { /* non-fatal — don't block the warning */ }
|
|
2124
|
+
|
|
1096
2125
|
return text({
|
|
1097
2126
|
warning: {
|
|
1098
|
-
type:
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
: `Someone may have been working on this. Check the branch before starting fresh.`,
|
|
2127
|
+
type: 'simultaneous_work_detected',
|
|
2128
|
+
situation,
|
|
2129
|
+
activeWorker: activeWorker || '(unknown)',
|
|
2130
|
+
isParked,
|
|
2131
|
+
branch: task.github.headBranch,
|
|
2132
|
+
parkNote: isParked ? task.parkNote : null,
|
|
2133
|
+
advice,
|
|
1106
2134
|
checkoutSteps: [
|
|
1107
2135
|
'git fetch origin',
|
|
1108
2136
|
`git checkout ${task.github.headBranch}`,
|
|
1109
2137
|
`git pull origin ${task.github.headBranch}`,
|
|
1110
2138
|
],
|
|
1111
2139
|
},
|
|
1112
|
-
brief: {
|
|
1113
|
-
key: task.key,
|
|
1114
|
-
title: task.title,
|
|
1115
|
-
priority: task.priority,
|
|
1116
|
-
},
|
|
2140
|
+
brief: { key: task.key, title: task.title, priority: task.priority },
|
|
1117
2141
|
requiresConfirmation: true,
|
|
1118
|
-
message:
|
|
2142
|
+
message: isParked
|
|
2143
|
+
? `Task is parked. Use unpark_task instead of kickoff_task to continue safely.`
|
|
2144
|
+
: `⚠️ ${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.`,
|
|
1119
2145
|
})
|
|
1120
2146
|
}
|
|
1121
2147
|
|
|
@@ -1151,6 +2177,21 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1151
2177
|
? `⏳ Plan is submitted and awaiting approval — you cannot create a branch until it is approved.`
|
|
1152
2178
|
: `⚠️ Plan is not yet submitted for approval. Submit it first, then create the branch.`
|
|
1153
2179
|
: null,
|
|
2180
|
+
decompositionWarning: (() => {
|
|
2181
|
+
if (!task.decomposition?.trim()) return null
|
|
2182
|
+
try {
|
|
2183
|
+
const dec = JSON.parse(task.decomposition)
|
|
2184
|
+
if (!dec.scoutReportPresent && !task.scoutReport?.trim())
|
|
2185
|
+
return `⚠️ This task has a decomposition plan but no scout report. Consider running scout_task before builders start.`
|
|
2186
|
+
return `Decomposition exists: ${dec.totalSubtasks} subtask(s), ${dec.executionOrder?.length || 0} execution group(s).`
|
|
2187
|
+
} catch { return null }
|
|
2188
|
+
})(),
|
|
2189
|
+
agentContext: {
|
|
2190
|
+
hasScoutReport: !!(task.scoutReport?.trim()),
|
|
2191
|
+
hasDecomposition: !!(task.decomposition?.trim()),
|
|
2192
|
+
claimedFiles: task.claimedFiles || [],
|
|
2193
|
+
agentRole: task.agentRole || null,
|
|
2194
|
+
},
|
|
1154
2195
|
requiresConfirmation: true,
|
|
1155
2196
|
message: approvalBlocks
|
|
1156
2197
|
? `Read the plan above, then follow workflowRoadmap — approval is required before you can branch and start coding.`
|
|
@@ -1158,6 +2199,32 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1158
2199
|
})
|
|
1159
2200
|
}
|
|
1160
2201
|
|
|
2202
|
+
// ── #1 Preflight: check dirty tree before writing cursor rules / moving task ──
|
|
2203
|
+
{
|
|
2204
|
+
const pCwd = repoPath || process.cwd()
|
|
2205
|
+
const pRoot = findRepoRoot(pCwd)
|
|
2206
|
+
if (pRoot) {
|
|
2207
|
+
try {
|
|
2208
|
+
const porcelain = runGit('status --porcelain=v1', pRoot)
|
|
2209
|
+
const { localState, modified } = parseGitStatus(porcelain)
|
|
2210
|
+
const currentBranchNow = runGit('branch --show-current', pRoot).trim()
|
|
2211
|
+
if (localState === 'modified' && task.github?.headBranch && currentBranchNow !== task.github.headBranch) {
|
|
2212
|
+
return text({
|
|
2213
|
+
blocked: true,
|
|
2214
|
+
reason: `You are on "${currentBranchNow}" with ${modified.length} modified file(s), but this task's branch is "${task.github.headBranch}".`,
|
|
2215
|
+
modifiedFiles: modified,
|
|
2216
|
+
choices: {
|
|
2217
|
+
stash: `git stash push -m "wip: before kickoff ${task.key} on ${currentBranchNow}"`,
|
|
2218
|
+
autoSwitch: `Switch to ${task.github.headBranch}: git stash && git checkout ${task.github.headBranch} && git stash pop`,
|
|
2219
|
+
cancel: 'Review your changes first, then retry kickoff_task',
|
|
2220
|
+
},
|
|
2221
|
+
message: 'Resolve the working tree before kicking off this task to avoid cross-task pollution.',
|
|
2222
|
+
})
|
|
2223
|
+
}
|
|
2224
|
+
} catch { /* non-fatal */ }
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
1161
2228
|
// ── Confirmed: move to in_progress and fetch recent commits ──
|
|
1162
2229
|
let recentCommits = []
|
|
1163
2230
|
try {
|
|
@@ -1176,10 +2243,18 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1176
2243
|
} catch { /* might already be in_progress */ }
|
|
1177
2244
|
}
|
|
1178
2245
|
|
|
1179
|
-
// Write cursor rules file to local repo immediately on kickoff
|
|
2246
|
+
// Write cursor rules file to local repo immediately on kickoff (with role injection if set)
|
|
1180
2247
|
let cursorRulesFile = null
|
|
1181
2248
|
if (hasCursorRules) {
|
|
1182
|
-
cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
|
|
2249
|
+
cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath, agentRole || null)
|
|
2250
|
+
} else if (agentRole) {
|
|
2251
|
+
// Even if no task-specific cursor rules, write role rules alone
|
|
2252
|
+
cursorRulesFile = writeCursorRulesFile(task.key, '(No task-specific rules — follow role constraints above.)', repoPath, agentRole)
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// Persist agentRole to server so board shows the active role
|
|
2256
|
+
if (agentRole) {
|
|
2257
|
+
api.patch(`/api/tasks/${taskId}`, { agentRole }).catch(() => {/* non-fatal */})
|
|
1183
2258
|
}
|
|
1184
2259
|
|
|
1185
2260
|
return text({
|
|
@@ -1190,14 +2265,23 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1190
2265
|
column: moved ? 'in_progress' : task.column,
|
|
1191
2266
|
branch: task.github?.headBranch || null,
|
|
1192
2267
|
subtasks,
|
|
2268
|
+
agentRole: agentRole || null,
|
|
1193
2269
|
},
|
|
1194
2270
|
implementationPlan: hasReadme ? task.readmeMarkdown : null,
|
|
1195
|
-
cursorRules: hasCursorRules
|
|
1196
|
-
? {
|
|
2271
|
+
cursorRules: hasCursorRules || agentRole
|
|
2272
|
+
? {
|
|
2273
|
+
active: true,
|
|
2274
|
+
agentRole: agentRole || null,
|
|
2275
|
+
rules: task.cursorRules || null,
|
|
2276
|
+
roleRules: agentRole ? ROLE_RULES[agentRole] : null,
|
|
2277
|
+
instruction: agentRole
|
|
2278
|
+
? `⚠️ AGENT ROLE: ${agentRole.toUpperCase()} — Follow the role behavioral constraints injected into the cursor rules file. These override default behavior.`
|
|
2279
|
+
: '⚠️ CURSOR RULES ACTIVE — You MUST follow every rule in the "rules" field for the entire duration of this task.',
|
|
2280
|
+
}
|
|
1197
2281
|
: { active: false },
|
|
1198
2282
|
cursorRulesFile: cursorRulesFile
|
|
1199
2283
|
? { written: true, path: cursorRulesFile, note: 'Rules file written to your repo. Cursor enforces it automatically on every prompt.' }
|
|
1200
|
-
: hasCursorRules ? { written: false, note: 'Could not write rules file — not inside a git repo.' } : null,
|
|
2284
|
+
: (hasCursorRules || agentRole) ? { written: false, note: 'Could not write rules file — not inside a git repo.' } : null,
|
|
1201
2285
|
recentCommits: recentCommits.slice(0, 5).map(c => ({
|
|
1202
2286
|
sha: c.sha?.slice(0, 7),
|
|
1203
2287
|
message: c.commit?.message?.split('\n')[0],
|
|
@@ -1206,7 +2290,15 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
|
|
|
1206
2290
|
})),
|
|
1207
2291
|
movedToInProgress: moved,
|
|
1208
2292
|
suggestedBranch: alreadyHasBranch ? null : suggestedBranch,
|
|
1209
|
-
nextStep:
|
|
2293
|
+
nextStep: agentRole === 'scout'
|
|
2294
|
+
? `You are in SCOUT mode. Read the codebase, then save your findings with update_task(scoutReport=...). Do NOT modify any files.`
|
|
2295
|
+
: agentRole === 'coordinator'
|
|
2296
|
+
? `You are in COORDINATOR mode. Read the README, then call decompose_task to break the work into parallel subtasks with file ownership.`
|
|
2297
|
+
: agentRole === 'builder'
|
|
2298
|
+
? `You are in BUILDER mode. Call claim_files first, then start coding on "${alreadyHasBranch ? task.github.headBranch : suggestedBranch}".`
|
|
2299
|
+
: agentRole === 'reviewer'
|
|
2300
|
+
? `You are in REVIEWER mode. Call review_pr to get the diff, then post_pr_review with your analysis.`
|
|
2301
|
+
: alreadyHasBranch
|
|
1210
2302
|
? `Branch "${task.github.headBranch}" already exists. Task is now In progress — start coding.`
|
|
1211
2303
|
: `Call create_branch to create "${suggestedBranch}" — it will check your local git state and move the task to In progress automatically.`,
|
|
1212
2304
|
})
|
|
@@ -1340,25 +2432,162 @@ function registerIssueTools(server) {
|
|
|
1340
2432
|
function registerApprovalTools(server) {
|
|
1341
2433
|
server.tool(
|
|
1342
2434
|
'submit_task_for_approval',
|
|
1343
|
-
'
|
|
2435
|
+
'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.',
|
|
1344
2436
|
{
|
|
1345
2437
|
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2438
|
+
title: z.string().describe('Short title for this approval request, e.g. "Experiment: Add caching layer"'),
|
|
2439
|
+
readme: z.string().describe('The plan/markdown describing what you want to do and why (min 80 chars)'),
|
|
1346
2440
|
reviewerId: z.string().describe('User ID of the reviewer'),
|
|
1347
2441
|
},
|
|
1348
|
-
async ({ taskId, reviewerId }) =>
|
|
1349
|
-
call(() => api.post(`/api/tasks/${taskId}/
|
|
2442
|
+
async ({ taskId, title, readme, reviewerId }) =>
|
|
2443
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals`, { title, readme, reviewerId }))
|
|
1350
2444
|
)
|
|
1351
2445
|
|
|
1352
2446
|
server.tool(
|
|
1353
2447
|
'decide_task_approval',
|
|
1354
|
-
'Approve or reject a
|
|
2448
|
+
'Approve or reject a specific approval request by approvalId. Only the designated reviewer can call this.',
|
|
1355
2449
|
{
|
|
1356
|
-
taskId:
|
|
1357
|
-
|
|
1358
|
-
|
|
2450
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2451
|
+
approvalId: z.string().describe("Approval request's MongoDB ObjectId (from the task's approvals array)"),
|
|
2452
|
+
decision: z.enum(['approve', 'reject']),
|
|
2453
|
+
note: z.string().optional().describe('Reason for the decision'),
|
|
1359
2454
|
},
|
|
1360
|
-
async ({ taskId, decision, note }) =>
|
|
1361
|
-
call(() => api.post(`/api/tasks/${taskId}/
|
|
2455
|
+
async ({ taskId, approvalId, decision, note }) =>
|
|
2456
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals/${approvalId}/decide`, { decision, note }))
|
|
2457
|
+
)
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function registerAIReviewTools(server) {
|
|
2461
|
+
server.tool(
|
|
2462
|
+
'list_pending_reviews',
|
|
2463
|
+
`List all tasks currently pending your review across all your projects.
|
|
2464
|
+
|
|
2465
|
+
Returns task key, title, plan length, who submitted, how long it has been waiting, and whether a PR is linked.
|
|
2466
|
+
Call this when you want to know "what needs reviewing?" or to start a review session.`,
|
|
2467
|
+
{},
|
|
2468
|
+
async () => {
|
|
2469
|
+
const projectsRes = await api.get('/api/projects')
|
|
2470
|
+
const projects = projectsRes?.data?.projects || []
|
|
2471
|
+
|
|
2472
|
+
const boards = await Promise.all(
|
|
2473
|
+
projects.map(async p => {
|
|
2474
|
+
try {
|
|
2475
|
+
const r = await api.get(`/api/projects/${p._id}`)
|
|
2476
|
+
return { project: r?.data?.project, tasks: r?.data?.tasks || [] }
|
|
2477
|
+
} catch { return null }
|
|
2478
|
+
})
|
|
2479
|
+
)
|
|
2480
|
+
|
|
2481
|
+
const meRes = await api.get('/api/auth/me')
|
|
2482
|
+
const meId = meRes?.data?.user?._id || ''
|
|
2483
|
+
|
|
2484
|
+
const pending = []
|
|
2485
|
+
const now = Date.now()
|
|
2486
|
+
for (const board of boards.filter(Boolean)) {
|
|
2487
|
+
for (const t of board.tasks) {
|
|
2488
|
+
const pendingApv = (t.approvals || []).find(a => a.state === 'pending')
|
|
2489
|
+
if (!pendingApv) continue
|
|
2490
|
+
const reviewerId = pendingApv?.reviewer?._id || pendingApv?.reviewer
|
|
2491
|
+
if (meId && reviewerId && String(reviewerId) !== String(meId)) continue
|
|
2492
|
+
const waitingMs = pendingApv?.requestedAt ? now - new Date(pendingApv.requestedAt).getTime() : 0
|
|
2493
|
+
const waitingHours = Math.round(waitingMs / (1000 * 60 * 60) * 10) / 10
|
|
2494
|
+
pending.push({
|
|
2495
|
+
taskId: t._id,
|
|
2496
|
+
approvalId: pendingApv._id,
|
|
2497
|
+
approvalTitle: pendingApv.title || '',
|
|
2498
|
+
key: t.key,
|
|
2499
|
+
title: t.title,
|
|
2500
|
+
project: board.project?.name || '',
|
|
2501
|
+
priority: t.priority,
|
|
2502
|
+
submittedBy: pendingApv?.requestedBy?.name || pendingApv?.requestedBy?.email || 'unknown',
|
|
2503
|
+
waitingHours,
|
|
2504
|
+
planChars: (pendingApv.readme || '').length,
|
|
2505
|
+
hasPR: !!(t.github?.prNumber),
|
|
2506
|
+
prUrl: t.github?.prUrl || null,
|
|
2507
|
+
hasAIReview: !!pendingApv?.aiReview,
|
|
2508
|
+
aiRecommendation: pendingApv?.aiReview?.recommendation || null,
|
|
2509
|
+
})
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
pending.sort((a, b) => b.waitingHours - a.waitingHours)
|
|
2514
|
+
|
|
2515
|
+
return text({
|
|
2516
|
+
count: pending.length,
|
|
2517
|
+
pendingReviews: pending,
|
|
2518
|
+
message: pending.length === 0
|
|
2519
|
+
? 'No tasks pending your review.'
|
|
2520
|
+
: `${pending.length} task(s) awaiting review. Call get_review_bundle with a taskId to start reviewing.`,
|
|
2521
|
+
})
|
|
2522
|
+
}
|
|
2523
|
+
)
|
|
2524
|
+
|
|
2525
|
+
server.tool(
|
|
2526
|
+
'get_review_bundle',
|
|
2527
|
+
`Get everything needed to review a task as an AI code reviewer.
|
|
2528
|
+
|
|
2529
|
+
Returns:
|
|
2530
|
+
- plan: full implementation README (what the developer planned to build)
|
|
2531
|
+
- prFiles: list of changed files in the PR with unified diffs (the actual code written)
|
|
2532
|
+
- recentCommits: last 20 commits on the task branch
|
|
2533
|
+
- comments: recent discussion on the task
|
|
2534
|
+
- openIssues: any open bugs logged on the task
|
|
2535
|
+
- approval: current approval state and any prior AI review
|
|
2536
|
+
|
|
2537
|
+
After calling this, analyze the plan vs the code diff and call submit_ai_review with your findings.`,
|
|
2538
|
+
{
|
|
2539
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2540
|
+
},
|
|
2541
|
+
async ({ taskId }) => call(() => api.get(`/api/tasks/${taskId}/review-bundle`))
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
server.tool(
|
|
2545
|
+
'submit_ai_review',
|
|
2546
|
+
`Submit a structured AI code review opinion on a specific approval request pending review.
|
|
2547
|
+
|
|
2548
|
+
This does NOT override the human reviewer's decision — it attaches your analysis so the
|
|
2549
|
+
human reviewer can approve/reject faster with full AI context.
|
|
2550
|
+
|
|
2551
|
+
Your review should cover:
|
|
2552
|
+
1. Does the experiment plan make sense and is it safe to implement?
|
|
2553
|
+
2. Are there security concerns, performance issues, or bugs in linked PR code?
|
|
2554
|
+
3. Is the plan clear and actionable?
|
|
2555
|
+
4. Are edge cases covered?
|
|
2556
|
+
|
|
2557
|
+
Workflow:
|
|
2558
|
+
1. Call list_pending_reviews to find tasks awaiting review (returns taskId + approvalId)
|
|
2559
|
+
2. Call get_review_bundle to get the full context (plan + PR diff + commits)
|
|
2560
|
+
3. Analyze everything thoroughly
|
|
2561
|
+
4. Call submit_ai_review with your structured findings`,
|
|
2562
|
+
{
|
|
2563
|
+
taskId: z.string().describe("Task's MongoDB ObjectId"),
|
|
2564
|
+
approvalId: z.string().describe("Approval request's MongoDB ObjectId (from list_pending_reviews)"),
|
|
2565
|
+
agentName: z.string().optional().default('Claude Code').describe('Name of the AI agent doing the review'),
|
|
2566
|
+
summary: z.string().describe(
|
|
2567
|
+
'Overall review narrative (2-5 paragraphs). Cover: plan quality, code quality, security, correctness.'
|
|
2568
|
+
),
|
|
2569
|
+
recommendation: z.enum(['approve', 'reject', 'needs_work']).describe(
|
|
2570
|
+
'"approve" = plan is solid; "needs_work" = minor issues; "reject" = significant problems'
|
|
2571
|
+
),
|
|
2572
|
+
confidence: z.number().min(0).max(100).describe(
|
|
2573
|
+
'How confident are you in this recommendation? 0 = very uncertain, 100 = very certain'
|
|
2574
|
+
),
|
|
2575
|
+
checklist: z.array(z.object({
|
|
2576
|
+
item: z.string().describe('What was checked (e.g. "Error handling in API calls")'),
|
|
2577
|
+
passed: z.boolean().describe('Did it pass?'),
|
|
2578
|
+
note: z.string().optional().describe('Brief explanation'),
|
|
2579
|
+
})).describe('Structured checklist of what was reviewed'),
|
|
2580
|
+
issues: z.array(z.object({
|
|
2581
|
+
severity: z.enum(['low', 'medium', 'high', 'critical']),
|
|
2582
|
+
description: z.string().describe('What the issue is'),
|
|
2583
|
+
suggestion: z.string().optional().describe('How to fix it'),
|
|
2584
|
+
})).optional().describe('Specific issues found in the code'),
|
|
2585
|
+
},
|
|
2586
|
+
async ({ taskId, approvalId, agentName, summary, recommendation, confidence, checklist, issues }) =>
|
|
2587
|
+
call(() => api.post(`/api/tasks/${taskId}/approvals/${approvalId}/ai-review`, {
|
|
2588
|
+
agentName, summary, recommendation, confidence, checklist,
|
|
2589
|
+
issues: issues || [],
|
|
2590
|
+
}))
|
|
1362
2591
|
)
|
|
1363
2592
|
}
|
|
1364
2593
|
|
|
@@ -1476,15 +2705,118 @@ function findRepoRoot(startPath) {
|
|
|
1476
2705
|
return null
|
|
1477
2706
|
}
|
|
1478
2707
|
|
|
1479
|
-
|
|
1480
|
-
|
|
2708
|
+
// ── Agent role behavioral rule templates ─────────────────────────────────────
|
|
2709
|
+
// Prepended to cursor rules when an agentRole is set. Defines what the agent
|
|
2710
|
+
// CAN and CANNOT do for the duration of the task session.
|
|
2711
|
+
const ROLE_RULES = {
|
|
2712
|
+
builder: `## Agent Role: BUILDER
|
|
2713
|
+
|
|
2714
|
+
You are a BUILDER agent. Your behavioral constraints for this session:
|
|
2715
|
+
|
|
2716
|
+
**ALLOWED:**
|
|
2717
|
+
- Write, modify, and delete code files within the task scope
|
|
2718
|
+
- Create new files required by the implementation plan
|
|
2719
|
+
- Commit and push changes on the task branch
|
|
2720
|
+
- Run tests and fix failures
|
|
2721
|
+
- Claim file ownership before editing (use claim_files MCP tool)
|
|
2722
|
+
|
|
2723
|
+
**NOT ALLOWED:**
|
|
2724
|
+
- Modify files owned by another in-progress task (check claimedFiles conflicts)
|
|
2725
|
+
- Make architectural decisions not in the implementation plan — flag them instead
|
|
2726
|
+
- Edit files outside the task scope without explicit approval
|
|
2727
|
+
- Merge to main/master/dev directly
|
|
2728
|
+
|
|
2729
|
+
**WORK STYLE:**
|
|
2730
|
+
- Read the implementation plan fully before writing any code
|
|
2731
|
+
- Claim your files at the start with claim_files
|
|
2732
|
+
- Follow the spec precisely — don't add unrequested features
|
|
2733
|
+
- Commit atomically with conventional commit format (feat/fix/refactor)`,
|
|
2734
|
+
|
|
2735
|
+
scout: `## Agent Role: SCOUT
|
|
2736
|
+
|
|
2737
|
+
You are a SCOUT agent. Your behavioral constraints for this session:
|
|
2738
|
+
|
|
2739
|
+
**ALLOWED:**
|
|
2740
|
+
- Read and analyze any file in the codebase
|
|
2741
|
+
- Search for patterns, dependencies, and potential conflicts
|
|
2742
|
+
- Write a structured scout report (update_task with scoutReport field)
|
|
2743
|
+
- Identify which files the implementation will need to touch
|
|
2744
|
+
- Surface risks, gotchas, and architectural considerations
|
|
2745
|
+
|
|
2746
|
+
**NOT ALLOWED:**
|
|
2747
|
+
- Modify any source code files
|
|
2748
|
+
- Create branches or commits
|
|
2749
|
+
- Move tasks or create PRs
|
|
2750
|
+
- Make changes that would require a PR
|
|
2751
|
+
|
|
2752
|
+
**WORK STYLE:**
|
|
2753
|
+
- Your output is a scout report for the builder agents
|
|
2754
|
+
- Map file dependencies before builders start
|
|
2755
|
+
- Identify conflict risks with other in-progress tasks
|
|
2756
|
+
- Estimate complexity and flag potential blockers
|
|
2757
|
+
- Save your report with update_task(scoutReport=...)`,
|
|
2758
|
+
|
|
2759
|
+
reviewer: `## Agent Role: REVIEWER
|
|
2760
|
+
|
|
2761
|
+
You are a REVIEWER agent. Your behavioral constraints for this session:
|
|
2762
|
+
|
|
2763
|
+
**ALLOWED:**
|
|
2764
|
+
- Read all code changes and diffs (use review_pr)
|
|
2765
|
+
- Post structured reviews with specific analysis points (use post_pr_review)
|
|
2766
|
+
- Request changes or approve the PR
|
|
2767
|
+
- Reference specific files, functions, and line numbers in your review
|
|
2768
|
+
|
|
2769
|
+
**NOT ALLOWED:**
|
|
2770
|
+
- Approve a PR without reading every file in the diff
|
|
2771
|
+
- Write "looks good" or generic analysis — be specific and file-based
|
|
2772
|
+
- Merge the PR without completing the review chain (review_pr → post_pr_review → merge_pr)
|
|
2773
|
+
- Modify source code directly
|
|
2774
|
+
|
|
2775
|
+
**WORK STYLE:**
|
|
2776
|
+
- Always call review_pr first to get the full diff and spec
|
|
2777
|
+
- Check each file against the implementation plan
|
|
2778
|
+
- Build analysisPoints with file-specific findings (minimum 2)
|
|
2779
|
+
- Reference function names and line numbers
|
|
2780
|
+
- Only call merge_pr after post_pr_review returns a reviewId`,
|
|
2781
|
+
|
|
2782
|
+
coordinator: `## Agent Role: COORDINATOR
|
|
2783
|
+
|
|
2784
|
+
You are a COORDINATOR agent. Your behavioral constraints for this session:
|
|
2785
|
+
|
|
2786
|
+
**ALLOWED:**
|
|
2787
|
+
- Read the task implementation plan and codebase structure
|
|
2788
|
+
- Decompose the plan into parallel subtasks (use decompose_task)
|
|
2789
|
+
- Assign file ownership to each subtask with no overlaps
|
|
2790
|
+
- Assign roles to each subtask (builder, scout, reviewer)
|
|
2791
|
+
- Create subtasks on the board (use update_task with subtasks)
|
|
2792
|
+
- Monitor progress and resolve file conflicts
|
|
2793
|
+
|
|
2794
|
+
**NOT ALLOWED:**
|
|
2795
|
+
- Write implementation code directly
|
|
2796
|
+
- Claim file ownership for yourself
|
|
2797
|
+
- Bypass the decomposition step — always decompose before builders start
|
|
2798
|
+
- Start coding without a scout report when the codebase is unfamiliar
|
|
2799
|
+
|
|
2800
|
+
**WORK STYLE:**
|
|
2801
|
+
- Start with decompose_task to get the structured execution plan
|
|
2802
|
+
- Ensure no two builder subtasks claim the same file
|
|
2803
|
+
- Scout report must exist before builders begin
|
|
2804
|
+
- Each builder subtask must have explicit file ownership and a clear scope`,
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
/** Write task-specific cursor rules to .cursor/rules/<taskKey>.mdc in the local repo root.
|
|
2808
|
+
* When role is provided, role-specific behavioral constraints are prepended. */
|
|
2809
|
+
function writeCursorRulesFile(taskKey, rulesMarkdown, startPath, role = null) {
|
|
1481
2810
|
try {
|
|
1482
2811
|
const repoRoot = findRepoRoot(startPath)
|
|
1483
2812
|
if (!repoRoot) return null
|
|
1484
2813
|
const rulesDir = join(repoRoot, '.cursor', 'rules')
|
|
1485
2814
|
mkdirSync(rulesDir, { recursive: true })
|
|
1486
2815
|
const filePath = join(rulesDir, `${taskKey.toLowerCase()}.mdc`)
|
|
1487
|
-
const
|
|
2816
|
+
const roleSection = role && ROLE_RULES[role]
|
|
2817
|
+
? `${ROLE_RULES[role]}\n\n---\n\n## Task-Specific Rules\n\n`
|
|
2818
|
+
: ''
|
|
2819
|
+
const content = `---\ndescription: Task-specific rules for ${taskKey}${role ? ` (role: ${role})` : ''} — auto-generated by InternalTool MCP. Do not edit manually.\nalwaysApply: true\n---\n\n${roleSection}${rulesMarkdown}\n`
|
|
1488
2820
|
writeFileSync(filePath, content, 'utf8')
|
|
1489
2821
|
return filePath
|
|
1490
2822
|
} catch {
|
|
@@ -1508,6 +2840,49 @@ function deleteCursorRulesFile(taskKey, startPath) {
|
|
|
1508
2840
|
}
|
|
1509
2841
|
}
|
|
1510
2842
|
|
|
2843
|
+
// ── #3 Better failure recovery: API call with auto-retry + structured cause ───
|
|
2844
|
+
async function apiWithRetry(fn, maxRetries = 2, initialDelay = 500) {
|
|
2845
|
+
let lastErr
|
|
2846
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2847
|
+
try {
|
|
2848
|
+
return await fn()
|
|
2849
|
+
} catch (e) {
|
|
2850
|
+
lastErr = e
|
|
2851
|
+
const msg = (e.message || '').toLowerCase()
|
|
2852
|
+
// Never retry auth / client errors
|
|
2853
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('unauthorized') || msg.includes('forbidden')) {
|
|
2854
|
+
throw Object.assign(e, { _cause: 'auth' })
|
|
2855
|
+
}
|
|
2856
|
+
if (msg.includes('404') || msg.includes('400') || msg.includes('422') || msg.includes('409')) {
|
|
2857
|
+
throw Object.assign(e, { _cause: 'client' })
|
|
2858
|
+
}
|
|
2859
|
+
if (attempt < maxRetries) {
|
|
2860
|
+
await new Promise(r => setTimeout(r, initialDelay * (attempt + 1)))
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
const msg = (lastErr?.message || '').toLowerCase()
|
|
2865
|
+
const cause = (msg.includes('econnref') || msg.includes('network') || msg.includes('fetch failed') || msg.includes('enotfound'))
|
|
2866
|
+
? 'network'
|
|
2867
|
+
: msg.includes('timeout') ? 'timeout' : 'server'
|
|
2868
|
+
lastErr._cause = cause
|
|
2869
|
+
throw lastErr
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function wrapApiError(e) {
|
|
2873
|
+
return { error: true, cause: e._cause || 'unknown', message: e.message?.split('\n')[0] }
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// ── #9 Get last git commit metadata ──────────────────────────────────────────
|
|
2877
|
+
function getLastCommitMeta(repoRoot) {
|
|
2878
|
+
if (!repoRoot) return null
|
|
2879
|
+
try {
|
|
2880
|
+
const raw = runGit('log -1 --format=%H%x09%s%x09%an%x09%ae%x09%ad --date=short', repoRoot)
|
|
2881
|
+
const [sha, subject, authorName, authorEmail, date] = raw.split('\t')
|
|
2882
|
+
return { sha: sha?.slice(0, 7), subject: subject?.trim(), author: authorName || authorEmail, date }
|
|
2883
|
+
} catch { return null }
|
|
2884
|
+
}
|
|
2885
|
+
|
|
1511
2886
|
function parseGitStatus(porcelain) {
|
|
1512
2887
|
const lines = porcelain.split('\n').filter(Boolean)
|
|
1513
2888
|
const staged = lines.filter(l => !' ?!'.includes(l[0])).map(l => ({ xy: l.slice(0, 2), file: l.slice(3) }))
|
|
@@ -1623,18 +2998,24 @@ The developer does not need to run any commands manually — this tool reads loc
|
|
|
1623
2998
|
server.tool(
|
|
1624
2999
|
'list_my_tasks',
|
|
1625
3000
|
`List your assigned tasks sorted by priority (critical → high → medium → low).
|
|
1626
|
-
Each task includes a suggested next git action
|
|
3001
|
+
Each task includes a suggested next git action, ownership status (who currently owns / parked it),
|
|
3002
|
+
and branch consistency warnings so Claude can guide you step by step.
|
|
1627
3003
|
Use this at the start of a session or when switching tasks.`,
|
|
1628
3004
|
{
|
|
1629
3005
|
includeColumns: z.array(
|
|
1630
3006
|
z.enum(['backlog', 'todo', 'in_progress', 'in_review', 'done'])
|
|
1631
3007
|
).optional().default(['todo', 'in_progress', 'in_review'])
|
|
1632
3008
|
.describe('Which columns to include. Default excludes backlog and done.'),
|
|
3009
|
+
repoPath: z.string().optional().describe('Absolute path to local repo — used for branch consistency checks'),
|
|
1633
3010
|
},
|
|
1634
|
-
async ({ includeColumns = ['todo', 'in_progress', 'in_review'] } = {}) => {
|
|
3011
|
+
async ({ includeColumns = ['todo', 'in_progress', 'in_review'], repoPath } = {}) => {
|
|
1635
3012
|
const res = await api.get('/api/users/me/tasks')
|
|
1636
3013
|
if (!res?.success) return errorText('Could not fetch tasks')
|
|
1637
3014
|
|
|
3015
|
+
// #4 Local repo for branch consistency checks
|
|
3016
|
+
const cwd = repoPath || process.cwd()
|
|
3017
|
+
const repoRoot = findRepoRoot(cwd)
|
|
3018
|
+
|
|
1638
3019
|
// Server already sorts by priority (critical → low) then updatedAt — just filter and annotate
|
|
1639
3020
|
const tasks = (res.data.tasks || [])
|
|
1640
3021
|
.filter(t => includeColumns.includes(t.column))
|
|
@@ -1649,6 +3030,44 @@ Use this at the start of a session or when switching tasks.`,
|
|
|
1649
3030
|
} else if (t.column === 'in_review') {
|
|
1650
3031
|
suggestedAction = `PR #${t.github?.prNumber || '?'} is open — waiting for reviewer feedback`
|
|
1651
3032
|
}
|
|
3033
|
+
|
|
3034
|
+
// ── #6 Ownership visibility ───────────────────────────────────────
|
|
3035
|
+
const currentOwner = t.parkNote?.currentOwner
|
|
3036
|
+
const parkedBy = t.parkNote?.parkedBy
|
|
3037
|
+
const ownership = {
|
|
3038
|
+
currentOwner: currentOwner
|
|
3039
|
+
? (currentOwner.name || currentOwner.email || String(currentOwner))
|
|
3040
|
+
: null,
|
|
3041
|
+
parkedBy: parkedBy
|
|
3042
|
+
? (parkedBy.name || parkedBy.email || String(parkedBy))
|
|
3043
|
+
: null,
|
|
3044
|
+
parkedAt: t.parkNote?.parkedAt || null,
|
|
3045
|
+
parkedSummary: t.parkNote?.summary || null,
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// ── #4 Branch consistency check ───────────────────────────────────
|
|
3049
|
+
let branchConsistency = null
|
|
3050
|
+
if (t.column === 'in_progress' && t.github?.headBranch && repoRoot) {
|
|
3051
|
+
try {
|
|
3052
|
+
let localExists = false, remoteExists = false
|
|
3053
|
+
try { runGit(`rev-parse --verify ${t.github.headBranch}`, repoRoot); localExists = true } catch { /* not local */ }
|
|
3054
|
+
try { runGit(`rev-parse --verify origin/${t.github.headBranch}`, repoRoot); remoteExists = true } catch { /* not remote */ }
|
|
3055
|
+
if (!localExists && !remoteExists) {
|
|
3056
|
+
branchConsistency = {
|
|
3057
|
+
warning: 'branch_not_found',
|
|
3058
|
+
message: `Branch "${t.github.headBranch}" does not exist locally or remotely.`,
|
|
3059
|
+
fixAction: `git fetch origin && git checkout ${t.github.headBranch} — or use create_branch if it was never pushed`,
|
|
3060
|
+
}
|
|
3061
|
+
} else if (!localExists && remoteExists) {
|
|
3062
|
+
branchConsistency = {
|
|
3063
|
+
warning: 'branch_remote_only',
|
|
3064
|
+
message: `Branch "${t.github.headBranch}" exists on remote but not locally.`,
|
|
3065
|
+
fixAction: `git fetch origin && git checkout ${t.github.headBranch}`,
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
} catch { /* non-fatal */ }
|
|
3069
|
+
}
|
|
3070
|
+
|
|
1652
3071
|
return {
|
|
1653
3072
|
id: t._id,
|
|
1654
3073
|
key: t.key,
|
|
@@ -1658,11 +3077,193 @@ Use this at the start of a session or when switching tasks.`,
|
|
|
1658
3077
|
project: t.project?.name || t.project,
|
|
1659
3078
|
github: t.github ? { headBranch: t.github.headBranch, prNumber: t.github.prNumber, mergedAt: t.github.mergedAt } : null,
|
|
1660
3079
|
parked: !!t.parkNote?.parkedAt,
|
|
3080
|
+
ownership,
|
|
3081
|
+
branchConsistency,
|
|
1661
3082
|
suggestedAction,
|
|
1662
3083
|
}
|
|
1663
3084
|
})
|
|
1664
3085
|
|
|
1665
|
-
|
|
3086
|
+
const consistencyIssues = tasks.filter(t => t.branchConsistency).map(t => ({ key: t.key, ...t.branchConsistency }))
|
|
3087
|
+
|
|
3088
|
+
return text({
|
|
3089
|
+
tasks,
|
|
3090
|
+
count: tasks.length,
|
|
3091
|
+
consistencyIssues: consistencyIssues.length ? consistencyIssues : null,
|
|
3092
|
+
})
|
|
3093
|
+
}
|
|
3094
|
+
)
|
|
3095
|
+
|
|
3096
|
+
// ── #7 find_repo ─────────────────────────────────────────────────────────────
|
|
3097
|
+
server.tool(
|
|
3098
|
+
'find_repo',
|
|
3099
|
+
`Scan the file system to find git repositories near a given path.
|
|
3100
|
+
|
|
3101
|
+
Use this when the user says "I can't find my repo", points to a parent folder,
|
|
3102
|
+
or when create_branch / unpark_task fail because repoPath is wrong.
|
|
3103
|
+
|
|
3104
|
+
Returns all git repos found within 2 directory levels, along with their current branch
|
|
3105
|
+
and whether they contain a .cursor/mcp.json (indicating InternalTool is configured there).`,
|
|
3106
|
+
{
|
|
3107
|
+
searchPath: z.string().optional().describe('Directory to scan from. Defaults to the MCP process working directory.'),
|
|
3108
|
+
},
|
|
3109
|
+
async ({ searchPath } = {}) => {
|
|
3110
|
+
const base = searchPath || process.cwd()
|
|
3111
|
+
const candidates = []
|
|
3112
|
+
|
|
3113
|
+
function tryRepo(dir) {
|
|
3114
|
+
try {
|
|
3115
|
+
const root = runGit('rev-parse --show-toplevel', dir)
|
|
3116
|
+
if (candidates.some(c => c.repoRoot === root)) return // already found
|
|
3117
|
+
let currentBranch = null, hasMcpConfig = false
|
|
3118
|
+
try { currentBranch = runGit('branch --show-current', root).trim() } catch { /* ok */ }
|
|
3119
|
+
try { hasMcpConfig = existsSync(join(root, '.cursor', 'mcp.json')) } catch { /* ok */ }
|
|
3120
|
+
candidates.push({ repoRoot: root, currentBranch, hasMcpConfig })
|
|
3121
|
+
} catch { /* not a git repo */ }
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// Level 0: the path itself
|
|
3125
|
+
tryRepo(base)
|
|
3126
|
+
|
|
3127
|
+
// Level 1: immediate subdirectories
|
|
3128
|
+
try {
|
|
3129
|
+
const entries = readdirSync(base, { withFileTypes: true })
|
|
3130
|
+
for (const e of entries) {
|
|
3131
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
3132
|
+
const sub = join(base, e.name)
|
|
3133
|
+
tryRepo(sub)
|
|
3134
|
+
// Level 2: one more level deep
|
|
3135
|
+
try {
|
|
3136
|
+
const sub2Entries = readdirSync(sub, { withFileTypes: true })
|
|
3137
|
+
for (const e2 of sub2Entries) {
|
|
3138
|
+
if (!e2.isDirectory() || e2.name.startsWith('.')) continue
|
|
3139
|
+
tryRepo(join(sub, e2.name))
|
|
3140
|
+
}
|
|
3141
|
+
} catch { /* ok */ }
|
|
3142
|
+
}
|
|
3143
|
+
} catch { /* ok */ }
|
|
3144
|
+
|
|
3145
|
+
if (!candidates.length) {
|
|
3146
|
+
return text({
|
|
3147
|
+
found: false,
|
|
3148
|
+
searchedFrom: base,
|
|
3149
|
+
message: `No git repositories found within 2 levels of "${base}". Try passing a searchPath closer to your project.`,
|
|
3150
|
+
})
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
return text({
|
|
3154
|
+
found: true,
|
|
3155
|
+
searchedFrom: base,
|
|
3156
|
+
repos: candidates,
|
|
3157
|
+
count: candidates.length,
|
|
3158
|
+
tip: candidates.length === 1
|
|
3159
|
+
? `Use repoPath="${candidates[0].repoRoot}" in create_branch / unpark_task / park_task.`
|
|
3160
|
+
: `Multiple repos found. Pass the correct one as repoPath to git workflow tools.`,
|
|
3161
|
+
})
|
|
3162
|
+
}
|
|
3163
|
+
)
|
|
3164
|
+
|
|
3165
|
+
// ── #8 validate_mcp_config ───────────────────────────────────────────────────
|
|
3166
|
+
server.tool(
|
|
3167
|
+
'validate_mcp_config',
|
|
3168
|
+
`Validate the .cursor/mcp.json configuration for InternalTool.
|
|
3169
|
+
|
|
3170
|
+
Checks for common issues:
|
|
3171
|
+
- File not found or not valid JSON
|
|
3172
|
+
- Missing required fields (command, args, env.INTERNALTOOL_TOKEN)
|
|
3173
|
+
- Trailing spaces or wrong separators in the --url value
|
|
3174
|
+
- Token not starting with "ilt_"
|
|
3175
|
+
- Mismatched project URL vs server URL
|
|
3176
|
+
|
|
3177
|
+
Use this when the MCP connection seems wrong or tools return unexpected auth errors.`,
|
|
3178
|
+
{
|
|
3179
|
+
repoPath: z.string().optional().describe('Path to search for .cursor/mcp.json (defaults to MCP working directory)'),
|
|
3180
|
+
},
|
|
3181
|
+
async ({ repoPath } = {}) => {
|
|
3182
|
+
const cwd = repoPath || process.cwd()
|
|
3183
|
+
const root = findRepoRoot(cwd) || cwd
|
|
3184
|
+
|
|
3185
|
+
const configPath = join(root, '.cursor', 'mcp.json')
|
|
3186
|
+
const issues = []
|
|
3187
|
+
const fixes = []
|
|
3188
|
+
|
|
3189
|
+
if (!existsSync(configPath)) {
|
|
3190
|
+
return text({
|
|
3191
|
+
valid: false,
|
|
3192
|
+
configPath,
|
|
3193
|
+
issues: ['File not found: .cursor/mcp.json does not exist in this repo'],
|
|
3194
|
+
fixes: [
|
|
3195
|
+
`Create it at: ${configPath}`,
|
|
3196
|
+
`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}`,
|
|
3197
|
+
],
|
|
3198
|
+
})
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
let parsed = null
|
|
3202
|
+
try {
|
|
3203
|
+
const { readFileSync: rfs } = await import('fs')
|
|
3204
|
+
const raw = rfs(configPath, 'utf8')
|
|
3205
|
+
// Check for trailing space in path (a common copy-paste mistake on macOS)
|
|
3206
|
+
if (configPath.includes(' ')) {
|
|
3207
|
+
issues.push(`Path contains a space: "${configPath}" — rename the folder to remove it`)
|
|
3208
|
+
fixes.push(`mv "${root}" "${root.replace(/ /g, '_')}"`)
|
|
3209
|
+
}
|
|
3210
|
+
parsed = JSON.parse(raw)
|
|
3211
|
+
} catch (e) {
|
|
3212
|
+
return text({
|
|
3213
|
+
valid: false,
|
|
3214
|
+
configPath,
|
|
3215
|
+
issues: [`Invalid JSON: ${e.message}`],
|
|
3216
|
+
fixes: ['Open .cursor/mcp.json and fix the JSON syntax. Use jsonlint.com to validate.'],
|
|
3217
|
+
})
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
const servers = parsed?.mcpServers || {}
|
|
3221
|
+
const serverNames = Object.keys(servers)
|
|
3222
|
+
if (!serverNames.length) {
|
|
3223
|
+
issues.push('mcpServers object is empty — no server configured')
|
|
3224
|
+
fixes.push('Add an "internaltool" entry under mcpServers')
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
for (const name of serverNames) {
|
|
3228
|
+
const cfg = servers[name]
|
|
3229
|
+
if (!cfg.command) { issues.push(`"${name}": missing "command" field`); fixes.push(`Set "command": "npx"`) }
|
|
3230
|
+
const args = cfg.args || []
|
|
3231
|
+
if (!args.includes('internaltool-mcp') && !args.some(a => a.includes('internaltool-mcp'))) {
|
|
3232
|
+
issues.push(`"${name}": args do not include "internaltool-mcp"`)
|
|
3233
|
+
fixes.push(`args should be: ["-y", "internaltool-mcp", "--url", "<your-server-url>"]`)
|
|
3234
|
+
}
|
|
3235
|
+
const urlIdx = args.indexOf('--url')
|
|
3236
|
+
if (urlIdx === -1) {
|
|
3237
|
+
issues.push(`"${name}": --url flag missing from args`)
|
|
3238
|
+
fixes.push(`Add "--url", "http://localhost:5001" to args`)
|
|
3239
|
+
} else {
|
|
3240
|
+
const urlVal = args[urlIdx + 1] || ''
|
|
3241
|
+
if (urlVal.endsWith(' ') || urlVal.startsWith(' ')) {
|
|
3242
|
+
issues.push(`"${name}": --url value has leading/trailing space: "${urlVal}"`)
|
|
3243
|
+
fixes.push(`Remove spaces: "${urlVal.trim()}"`)
|
|
3244
|
+
}
|
|
3245
|
+
if (!urlVal.startsWith('http://') && !urlVal.startsWith('https://')) {
|
|
3246
|
+
issues.push(`"${name}": --url value looks invalid: "${urlVal}"`)
|
|
3247
|
+
fixes.push(`Should start with http:// or https://`)
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
const token = cfg.env?.INTERNALTOOL_TOKEN
|
|
3251
|
+
if (!token) {
|
|
3252
|
+
issues.push(`"${name}": INTERNALTOOL_TOKEN not set in env`)
|
|
3253
|
+
fixes.push(`Add: "env": { "INTERNALTOOL_TOKEN": "ilt_your_token" }`)
|
|
3254
|
+
} else if (!token.startsWith('ilt_')) {
|
|
3255
|
+
issues.push(`"${name}": INTERNALTOOL_TOKEN does not start with "ilt_" — may be wrong`)
|
|
3256
|
+
fixes.push(`Regenerate your token in InternalTool → Settings → API Keys`)
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
return text({
|
|
3261
|
+
valid: issues.length === 0,
|
|
3262
|
+
configPath,
|
|
3263
|
+
serverNames,
|
|
3264
|
+
issues: issues.length ? issues : ['None — config looks correct'],
|
|
3265
|
+
fixes: fixes.length ? fixes : ['No fixes needed'],
|
|
3266
|
+
})
|
|
1666
3267
|
}
|
|
1667
3268
|
)
|
|
1668
3269
|
|
|
@@ -1677,10 +3278,15 @@ Use this when returning to a task after a break, or when Claude needs the full p
|
|
|
1677
3278
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
|
|
1678
3279
|
},
|
|
1679
3280
|
async ({ taskId, repoPath }) => {
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
3281
|
+
let taskRes, activityRes
|
|
3282
|
+
try {
|
|
3283
|
+
[taskRes, activityRes] = await Promise.all([
|
|
3284
|
+
apiWithRetry(() => api.get(`/api/tasks/${taskId}`)),
|
|
3285
|
+
apiWithRetry(() => api.get(`/api/tasks/${taskId}/activity`)).catch(() => null),
|
|
3286
|
+
])
|
|
3287
|
+
} catch (e) {
|
|
3288
|
+
return text({ error: true, ...wrapApiError(e), message: `Could not fetch task context — ${wrapApiError(e).cause} error. Retry in a moment.` })
|
|
3289
|
+
}
|
|
1684
3290
|
if (!taskRes?.success) return errorText('Task not found')
|
|
1685
3291
|
const task = taskRes.data.task
|
|
1686
3292
|
const recentActivity = (activityRes?.data?.activity || []).slice(-10)
|
|
@@ -1794,8 +3400,9 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1794
3400
|
fromRef: z.string().optional().describe("Base ref to branch from (default: project's default branch)"),
|
|
1795
3401
|
confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the preview to create the branch'),
|
|
1796
3402
|
repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
|
|
3403
|
+
autoStash: z.boolean().optional().default(false).describe('Auto-stash modified files before creating the branch instead of blocking. Stash is labelled for easy recovery.'),
|
|
1797
3404
|
},
|
|
1798
|
-
async ({ taskId, projectId, fromRef, confirmed = false, repoPath }) => {
|
|
3405
|
+
async ({ taskId, projectId, fromRef, confirmed = false, repoPath, autoStash = false }) => {
|
|
1799
3406
|
if (scopedProjectId && projectId !== scopedProjectId) {
|
|
1800
3407
|
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
1801
3408
|
}
|
|
@@ -1807,9 +3414,9 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1807
3414
|
// ── Approval gate check ───────────────────────────────────────────────────
|
|
1808
3415
|
// The server blocks non-admins from moving todo → in_progress without approval.
|
|
1809
3416
|
// Detect this early and guide the developer instead of failing silently after branch creation.
|
|
1810
|
-
const
|
|
3417
|
+
const hasApprovedApv2 = (task.approvals || []).some(a => a.state === 'approved')
|
|
1811
3418
|
const PLANNING_COLS = ['backlog', 'todo']
|
|
1812
|
-
const needsApproval = PLANNING_COLS.includes(task.column) &&
|
|
3419
|
+
const needsApproval = PLANNING_COLS.includes(task.column) && !hasApprovedApv2
|
|
1813
3420
|
if (needsApproval && !confirmed) {
|
|
1814
3421
|
const isFix2 = /\b(fix|bug|hotfix|patch)\b/i.test(task.title + ' ' + (task.description || ''))
|
|
1815
3422
|
const slug2 = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 35)
|
|
@@ -1913,18 +3520,32 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
|
|
|
1913
3520
|
})
|
|
1914
3521
|
}
|
|
1915
3522
|
|
|
1916
|
-
// Step 2 — block
|
|
3523
|
+
// Step 2 — block or auto-stash if there are tracked changes
|
|
1917
3524
|
if (gitState?.localState === 'modified') {
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
3525
|
+
if (autoStash) {
|
|
3526
|
+
try {
|
|
3527
|
+
const stashLabel = `auto-stash(${task.key?.toLowerCase()}): before create_branch ${branchName}`
|
|
3528
|
+
runGit(`stash push -m "${stashLabel}"`, cwd)
|
|
3529
|
+
} catch (stashErr) {
|
|
3530
|
+
return text({
|
|
3531
|
+
blocked: true,
|
|
3532
|
+
reason: `autoStash failed: ${stashErr.message.split('\n')[0]}`,
|
|
3533
|
+
message: 'Stash your changes manually and retry.',
|
|
3534
|
+
})
|
|
3535
|
+
}
|
|
3536
|
+
} else {
|
|
3537
|
+
return text({
|
|
3538
|
+
blocked: true,
|
|
3539
|
+
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.`,
|
|
3540
|
+
modifiedFiles: gitState.modified,
|
|
3541
|
+
fixOptions: {
|
|
3542
|
+
stash: `git stash push -m "wip: switching to ${branchName}"`,
|
|
3543
|
+
commit: 'git add . && git commit -m "chore: wip"',
|
|
3544
|
+
autoStash: `Call create_branch again with autoStash=true — MCP will stash automatically`,
|
|
3545
|
+
},
|
|
3546
|
+
message: 'Stash or commit your changes, then call create_branch again with confirmed=true.',
|
|
3547
|
+
})
|
|
3548
|
+
}
|
|
1928
3549
|
}
|
|
1929
3550
|
|
|
1930
3551
|
// Step 3 — create branch on GitHub (or confirm it already exists), then link + move
|
|
@@ -2261,10 +3882,27 @@ Set confirmed=false first to preview the full PR content, then confirmed=true to
|
|
|
2261
3882
|
return errorText(`Access denied: session is scoped to project ${scopedProjectId}`)
|
|
2262
3883
|
}
|
|
2263
3884
|
|
|
2264
|
-
|
|
3885
|
+
let taskRes
|
|
3886
|
+
try { taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`)) }
|
|
3887
|
+
catch (e) { return text({ error: true, ...wrapApiError(e), message: `Could not fetch task — ${wrapApiError(e).cause} error. Retry raise_pr in a moment.` }) }
|
|
2265
3888
|
if (!taskRes?.success) return errorText('Task not found')
|
|
2266
3889
|
const task = taskRes.data.task
|
|
2267
3890
|
|
|
3891
|
+
// ── #5 Idempotent: return existing PR instead of trying to recreate ─────
|
|
3892
|
+
if (task.github?.prUrl && task.github?.prNumber) {
|
|
3893
|
+
const msg = confirmed
|
|
3894
|
+
? `PR #${task.github.prNumber} already exists for ${task.key} — no duplicate created.`
|
|
3895
|
+
: `PR #${task.github.prNumber} already exists for this branch. No action needed.`
|
|
3896
|
+
return text({
|
|
3897
|
+
prAlreadyExists: true,
|
|
3898
|
+
prNumber: task.github.prNumber,
|
|
3899
|
+
prUrl: task.github.prUrl,
|
|
3900
|
+
headBranch: task.github.headBranch || headBranch,
|
|
3901
|
+
message: msg,
|
|
3902
|
+
nextStep: `PR is live at ${task.github.prUrl}. The GitHub webhook tracks review status automatically.`,
|
|
3903
|
+
})
|
|
3904
|
+
}
|
|
3905
|
+
|
|
2268
3906
|
const prTitle = `[${task.key}] ${task.title}`
|
|
2269
3907
|
const bodyParts = [
|
|
2270
3908
|
`## ${task.key}: ${task.title}`,
|
|
@@ -2799,6 +4437,7 @@ async function main() {
|
|
|
2799
4437
|
registerProductivityTools(server)
|
|
2800
4438
|
registerIssueTools(server)
|
|
2801
4439
|
registerApprovalTools(server)
|
|
4440
|
+
registerAIReviewTools(server)
|
|
2802
4441
|
registerCommentTools(server)
|
|
2803
4442
|
registerNotificationTools(server)
|
|
2804
4443
|
registerGithubTools(server, ctx)
|