internaltool-mcp 1.6.22 → 1.6.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +910 -53
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -391,11 +391,16 @@ Set confirmed=false first to preview, then confirmed=true to execute everything.
391
391
  // ── Save park note + server-side comment & notifications ──────────────
392
392
  await api.patch(`/api/tasks/${taskId}/park`, { summary, remaining, blockers })
393
393
 
394
- // ── Delete cursor rules file ───────────────────────────────────────────
394
+ // ── Delete cursor rules file + task-scoped workspace files ───────────────
395
395
  let cursorRulesCleared = null
396
396
  if (task?.cursorRules?.trim()) {
397
397
  cursorRulesCleared = deleteCursorRulesFile(task.key, repoPath)
398
398
  }
399
+ deleteCursorWorkspace(task?.agentRole || null, repoPath)
400
+ // Mark workspace as cleared in DB so UI shows "not active"
401
+ api.patch(`/api/tasks/${taskId}`, {
402
+ agentWorkspace: { clearedAt: new Date().toISOString() }
403
+ }).catch(() => {/* non-fatal */})
399
404
 
400
405
  // ── #9 Capture last commit for handoff metadata ───────────────────────
401
406
  const lastCommit = getLastCommitMeta(repoRoot)
@@ -434,8 +439,11 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
434
439
  confirmed: z.boolean().optional().default(false).describe('Set true to execute after reviewing the park note'),
435
440
  repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory).'),
436
441
  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.'),
442
+ agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
443
+ .describe('Set the agent role for this task session. Role-specific behavioral constraints are injected into cursor rules.'),
437
444
  },
438
- async ({ taskId, confirmed = false, repoPath, autoStash = false }) => {
445
+ async ({ taskId, confirmed = false, repoPath, autoStash = false, agentRole }) => {
446
+ trackTaskActivity(taskId, 'unpark_task')
439
447
  const taskRes = await api.get(`/api/tasks/${taskId}`)
440
448
  const task = taskRes?.data?.task
441
449
  const branch = task?.github?.headBranch || null
@@ -546,10 +554,21 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
546
554
  // ── Clear park note + server-side comment & notification ──────────────
547
555
  await api.patch(`/api/tasks/${taskId}/unpark`, {})
548
556
 
549
- // ── Restore cursor rules file ──────────────────────────────────────────
557
+ // ── Restore cursor rules file (with role injection if set) ───────────────
550
558
  let cursorRulesFile = null
551
- if (task?.cursorRules?.trim()) {
552
- cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
559
+ const hasCursorRulesUnpark = task?.cursorRules?.trim()
560
+ if (hasCursorRulesUnpark || agentRole) {
561
+ cursorRulesFile = writeCursorRulesFile(
562
+ task.key,
563
+ hasCursorRulesUnpark || '(No task-specific rules — follow role constraints above.)',
564
+ repoPath,
565
+ agentRole || null
566
+ )
567
+ }
568
+
569
+ // Persist agentRole to server if set
570
+ if (agentRole) {
571
+ api.patch(`/api/tasks/${taskId}`, { agentRole }).catch(() => {/* non-fatal */})
553
572
  }
554
573
 
555
574
  // ── #9 Last commit metadata for handoff context ───────────────────────
@@ -557,17 +576,368 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
557
576
 
558
577
  return text({
559
578
  unparked: true,
560
- task: { key: task?.key, title: task?.title },
579
+ task: { key: task?.key, title: task?.title, agentRole: agentRole || null },
561
580
  git: gitResult || { switched: false, reason: 'No branch linked or repo not found' },
562
581
  autoStashed: autoStash ? 'Changes were auto-stashed before branch switch. Run: git stash list to see them.' : null,
563
582
  lastCommit: lastCommit || null,
564
- cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
583
+ cursorRules: cursorRulesFile
584
+ ? { restored: true, path: cursorRulesFile, agentRole: agentRole || null }
585
+ : { restored: false },
565
586
  commentPosted: true,
566
587
  previousDevNotified: true,
567
588
  parkNote: task?.parkNote || null,
568
589
  message: gitResult?.switched
569
- ? `You are now on branch "${branch}". Cursor rules restored. Start coding from where ${task?.parkNote?.parkedBy ? 'the previous developer' : 'you'} left off.`
590
+ ? `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.`
570
591
  : `Branch switch failed — see git.manualSteps. Cursor rules restored.`,
592
+ nextStep: agentRole === 'builder'
593
+ ? `BUILDER role active. Call claim_files to lock your files before editing.`
594
+ : agentRole === 'scout'
595
+ ? `SCOUT role active. Read-only mode — map the codebase and save findings with update_task(scoutReport=...).`
596
+ : agentRole === 'coordinator'
597
+ ? `COORDINATOR role active. Call decompose_task to plan parallel workstreams.`
598
+ : agentRole === 'reviewer'
599
+ ? `REVIEWER role active. Call review_pr to start the review chain.`
600
+ : null,
601
+ })
602
+ }
603
+ )
604
+
605
+ // ── claim_files ──────────────────────────────────────────────────────────────
606
+ server.tool(
607
+ 'claim_files',
608
+ `Claim exclusive ownership of files for this task session.
609
+
610
+ Prevents merge conflicts by ensuring no two in-progress tasks edit the same file.
611
+ Call this at the start of a builder session before editing any files.
612
+
613
+ Returns a conflict list if any claimed files are already owned by another in-progress task.
614
+ Ownership is automatically released when the task is parked.
615
+
616
+ Roles: BUILDER agents MUST call this before editing. Coordinators call this as part of decompose_task.`,
617
+ {
618
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
619
+ 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"])'),
620
+ },
621
+ async ({ taskId, files }) => {
622
+ trackTaskActivity(taskId, 'claim_files')
623
+ const res = await api.post(`/api/tasks/${taskId}/files/claim`, { files })
624
+ if (!res?.success) {
625
+ if (res?.conflicts) {
626
+ return text({
627
+ blocked: true,
628
+ reason: 'File ownership conflict — these files are already claimed by another in-progress task.',
629
+ conflicts: res.conflicts,
630
+ message: 'Wait for the conflicting task to release the files, or coordinate with the other developer to resolve the overlap.',
631
+ })
632
+ }
633
+ return errorText(res?.message || 'Could not claim files')
634
+ }
635
+ return text({
636
+ claimed: true,
637
+ files,
638
+ taskId,
639
+ message: `${files.length} file(s) claimed. No other in-progress task in this project may edit these files until you release or park.`,
640
+ nextStep: 'You may now safely edit the claimed files. Files are auto-released when you call park_task.',
641
+ })
642
+ }
643
+ )
644
+
645
+ // ── release_files ─────────────────────────────────────────────────────────────
646
+ server.tool(
647
+ 'release_files',
648
+ `Release all file ownership claims for a task.
649
+
650
+ Call this when a builder subtask is complete and another task needs to edit the same files.
651
+ Files are also released automatically when the task is parked.`,
652
+ {
653
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
654
+ },
655
+ async ({ taskId }) => {
656
+ const res = await api.post(`/api/tasks/${taskId}/files/release`, {})
657
+ if (!res?.success) return errorText(res?.message || 'Could not release files')
658
+ return text({
659
+ released: true,
660
+ taskId,
661
+ message: 'All file claims released. Other tasks may now claim these files.',
662
+ })
663
+ }
664
+ )
665
+
666
+ // ── decompose_task ────────────────────────────────────────────────────────────
667
+ server.tool(
668
+ 'decompose_task',
669
+ `Coordinator tool: decompose an implementation plan into parallel subtasks with file ownership and role assignments.
670
+
671
+ Use this when you are acting as a COORDINATOR agent and need to break a large task into
672
+ parallel workstreams that multiple builder agents can execute without stepping on each other.
673
+
674
+ What this does:
675
+ 1. Reads the task's implementation plan (README)
676
+ 2. Analyzes the plan to identify independent work units
677
+ 3. Returns a structured execution plan with:
678
+ - Parallel subtask groups (tasks that can run simultaneously)
679
+ - File ownership per subtask (no overlaps — each file appears in exactly one subtask)
680
+ - Role assignment per subtask (builder/scout/reviewer)
681
+ - Dependency order (which subtasks must complete before others start)
682
+ 4. Saves the decomposition plan to the task (decomposition field)
683
+ 5. Optionally creates the subtasks on the board
684
+
685
+ REQUIRED BEFORE BUILDERS START:
686
+ - A scout report should exist (call kickoff_task with agentRole=scout first)
687
+ - The README must describe what needs to be built
688
+
689
+ Call confirmed=false to preview the decomposition, confirmed=true to save it.`,
690
+ {
691
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
692
+ subtaskPlan: z.array(z.object({
693
+ title: z.string().describe('Subtask title'),
694
+ role: z.enum(['builder', 'scout', 'reviewer']).describe('Agent role for this subtask'),
695
+ files: z.array(z.string()).describe('Files this subtask exclusively owns (no overlap with other subtasks)'),
696
+ description: z.string().describe('What this subtask implements'),
697
+ dependsOn: z.array(z.string()).optional().describe('Titles of subtasks that must complete before this one starts'),
698
+ parallel: z.boolean().optional().default(true).describe('Can this subtask run in parallel with others at the same level?'),
699
+ })).min(1).describe('Decomposed subtask execution plan. File ownership must not overlap between subtasks.'),
700
+ confirmed: z.boolean().optional().default(false).describe('Set true to save the decomposition and create subtasks on the board'),
701
+ },
702
+ async ({ taskId, subtaskPlan, confirmed = false }) => {
703
+ trackTaskActivity(taskId, 'decompose_task')
704
+ const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
705
+ if (!taskRes?.success) return errorText('Task not found')
706
+ const task = taskRes.data.task
707
+
708
+ // Validate: no file overlap between subtasks
709
+ const fileOwnershipMap = {}
710
+ const conflicts = []
711
+ for (const subtask of subtaskPlan) {
712
+ for (const file of (subtask.files || [])) {
713
+ if (fileOwnershipMap[file]) {
714
+ conflicts.push({ file, claimedBy: [fileOwnershipMap[file], subtask.title] })
715
+ } else {
716
+ fileOwnershipMap[file] = subtask.title
717
+ }
718
+ }
719
+ }
720
+
721
+ if (conflicts.length > 0) {
722
+ return text({
723
+ blocked: true,
724
+ reason: 'File ownership overlap detected — each file must appear in exactly one subtask.',
725
+ conflicts,
726
+ message: 'Fix the overlapping file assignments before confirming the decomposition.',
727
+ })
728
+ }
729
+
730
+ // Group subtasks by dependency level for parallel execution visualization
731
+ const parallelGroups = []
732
+ const placed = new Set()
733
+ let safetyLimit = subtaskPlan.length + 1
734
+ while (placed.size < subtaskPlan.length && safetyLimit-- > 0) {
735
+ const batch = subtaskPlan.filter(s =>
736
+ !placed.has(s.title) &&
737
+ (s.dependsOn || []).every(dep => placed.has(dep))
738
+ )
739
+ if (!batch.length) break
740
+ parallelGroups.push(batch.map(s => s.title))
741
+ batch.forEach(s => placed.add(s.title))
742
+ }
743
+
744
+ const executionPlan = {
745
+ taskKey: task.key,
746
+ taskTitle: task.title,
747
+ totalSubtasks: subtaskPlan.length,
748
+ subtasks: subtaskPlan.map(s => ({
749
+ title: s.title,
750
+ role: s.role,
751
+ files: s.files,
752
+ description: s.description,
753
+ dependsOn: s.dependsOn || [],
754
+ parallel: s.parallel !== false,
755
+ })),
756
+ executionOrder: parallelGroups,
757
+ fileOwnershipMap,
758
+ scoutReportPresent: !!(task.scoutReport?.trim()),
759
+ readmePresent: !!(task.readmeMarkdown?.trim()),
760
+ }
761
+
762
+ if (!confirmed) {
763
+ return text({
764
+ decomposition: executionPlan,
765
+ warnings: [
766
+ !executionPlan.scoutReportPresent ? '⚠️ No scout report on this task. Consider running a scout agent first to map the codebase before builders start.' : null,
767
+ !executionPlan.readmePresent ? '⚠️ No implementation plan (README). Builders need a spec to work from.' : null,
768
+ ].filter(Boolean),
769
+ requiresConfirmation: true,
770
+ message: 'Review the decomposition above. Call decompose_task again with confirmed=true to save it and create the subtasks on the board.',
771
+ })
772
+ }
773
+
774
+ // Save decomposition to task
775
+ const decompositionJson = JSON.stringify(executionPlan, null, 2)
776
+ try {
777
+ await api.patch(`/api/tasks/${taskId}`, { decomposition: decompositionJson })
778
+ } catch { /* non-fatal — decomposition is returned regardless */ }
779
+
780
+ // Create subtasks on the board
781
+ const currentSubtasks = task.subtasks || []
782
+ const newSubtasks = [
783
+ ...currentSubtasks,
784
+ ...subtaskPlan.map((s, i) => ({
785
+ title: `[${s.role.toUpperCase()}] ${s.title}`,
786
+ done: false,
787
+ order: currentSubtasks.length + i,
788
+ })),
789
+ ]
790
+ try {
791
+ await api.patch(`/api/tasks/${taskId}`, { subtasks: newSubtasks })
792
+ } catch { /* non-fatal */ }
793
+
794
+ return text({
795
+ decomposed: true,
796
+ taskKey: task.key,
797
+ executionPlan,
798
+ subtasksCreated: subtaskPlan.length,
799
+ message: `Decomposition saved. ${subtaskPlan.length} subtask(s) added to the board.`,
800
+ nextStep: parallelGroups.length > 0
801
+ ? `Start with parallel group 1: ${parallelGroups[0].join(', ')}. Each builder agent should call kickoff_task with agentRole="builder" and claim_files before coding.`
802
+ : 'Start the subtasks in order. Each builder should call kickoff_task with agentRole="builder" and claim_files.',
803
+ })
804
+ }
805
+ )
806
+
807
+ // ── scout_task ────────────────────────────────────────────────────────────────
808
+ server.tool(
809
+ 'scout_task',
810
+ `Scout agent entry point: analyze the codebase for a task and save a structured scout report.
811
+
812
+ Call this BEFORE builders start when the Coordinator needs a codebase map.
813
+ Use agentRole="scout" in kickoff_task first, then call scout_task to get your briefing.
814
+
815
+ Two-phase flow:
816
+ Phase 1 — confirmed=false: get the briefing (what to analyze, report format)
817
+ Phase 2 — confirmed=true + report: save your findings to the task
818
+
819
+ The scout report is consumed by:
820
+ - decompose_task (warns if missing)
821
+ - get_agent_context (included in builder context)
822
+ - kickoff_task (surfaced as part of builder brief)
823
+
824
+ Scouts MUST NOT modify any source code files or create branches.`,
825
+ {
826
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
827
+ confirmed: z.boolean().optional().default(false).describe('Set true to save the report. Set false (default) to get the briefing on what to analyze.'),
828
+ report: z.string().optional().default('').describe('Your structured scout findings in markdown (required when confirmed=true)'),
829
+ },
830
+ async ({ taskId, confirmed = false, report = '' }) => {
831
+ trackTaskActivity(taskId, 'scout_task')
832
+ const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
833
+ if (!taskRes?.success) return errorText('Task not found')
834
+ const task = taskRes.data.task
835
+
836
+ if (!confirmed) {
837
+ return text({
838
+ brief: {
839
+ key: task.key,
840
+ title: task.title,
841
+ implementationPlan: task.readmeMarkdown || '(no README — ask coordinator to write one first)',
842
+ },
843
+ currentScoutReport: task.scoutReport?.trim() || null,
844
+ scoutInstructions: {
845
+ objective: 'Map the codebase as it relates to this task. Identify every file that will need to change.',
846
+ analyzeThese: [
847
+ 'Entry points — where does execution start for this feature area?',
848
+ 'File dependency graph — which files import from which?',
849
+ 'Existing patterns — what conventions does this codebase use (naming, folder structure, abstractions)?',
850
+ 'Conflict risks — are any in-progress tasks touching the same files?',
851
+ 'Complexity estimate — lines of code, number of files, coupling, test coverage',
852
+ ],
853
+ reportFormat: {
854
+ summary: 'One paragraph overview of what this task touches in the codebase',
855
+ entryPoints: ['file/path/entry.ts', '...'],
856
+ keyFiles: [{ path: 'file/path.ts', role: 'what it does for this task' }],
857
+ risks: ['Potential gotchas, breaking changes, or hidden complexity'],
858
+ suggestedFileOwnership: { 'Subtask Name': ['file1.ts', 'file2.ts'] },
859
+ complexityEstimate: 'low | medium | high',
860
+ },
861
+ },
862
+ requiresConfirmation: true,
863
+ message: `Analyze the codebase for ${task.key}, then call scout_task again with confirmed=true and your report string.`,
864
+ })
865
+ }
866
+
867
+ if (!report.trim()) return errorText('report is required when confirmed=true. Pass your scout findings as a markdown string.')
868
+
869
+ try {
870
+ await api.patch(`/api/tasks/${taskId}`, { scoutReport: report })
871
+ } catch (e) {
872
+ return errorText(`Failed to save scout report: ${e?.message || 'unknown error'}`)
873
+ }
874
+
875
+ return text({
876
+ saved: true,
877
+ taskKey: task.key,
878
+ message: `Scout report saved for ${task.key}.`,
879
+ nextStep: 'The coordinator can now call decompose_task — the scout report will be included in builder context automatically via get_agent_context.',
880
+ })
881
+ }
882
+ )
883
+
884
+ // ── get_agent_context ─────────────────────────────────────────────────────────
885
+ server.tool(
886
+ 'get_agent_context',
887
+ `Get a complete context package for starting work on a task as a specific agent role.
888
+
889
+ Call this instead of get_task when starting a session — it returns everything composed and
890
+ ready to use: systemPrompt, role behavioral constraints, implementation plan, cursor rules,
891
+ scout report, decomposition plan, claimed files, and project-level custom guidance.
892
+
893
+ Use this as the single entry point for any agent picking up a task. Replaces the need to
894
+ call get_task + kickoff_task preview separately.
895
+
896
+ Returns systemPrompt ready to use as a Claude system prompt.`,
897
+ {
898
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
899
+ role: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
900
+ .describe('Agent role for this session. Falls back to task.agentRole if omitted.'),
901
+ },
902
+ async ({ taskId, role }) => {
903
+ trackTaskActivity(taskId, 'get_agent_context')
904
+ const qs = role ? `?role=${role}` : ''
905
+ const res = await apiWithRetry(() => api.get(`/api/tasks/${taskId}/agent-context${qs}`))
906
+ if (!res?.success) return errorText(res?.message || 'Failed to get agent context')
907
+ const ctx = res.data
908
+
909
+ const effectiveRole = ctx.role
910
+ const roleRules = effectiveRole && ROLE_RULES[effectiveRole] ? ROLE_RULES[effectiveRole] : null
911
+
912
+ // Compose the full system prompt
913
+ const parts = []
914
+ if (effectiveRole) {
915
+ const roleLabel = ctx.customRoleLabel || effectiveRole.toUpperCase()
916
+ parts.push(`You are a ${roleLabel} agent working on task ${ctx.taskKey}: "${ctx.taskTitle}".`)
917
+ }
918
+ if (roleRules) parts.push(roleRules)
919
+ if (ctx.customPromptHint) parts.push(`\n## Project-Specific Guidance\n\n${ctx.customPromptHint}`)
920
+ if (ctx.task?.cursorRules?.trim()) parts.push(`\n## Task-Specific Cursor Rules\n\n${ctx.task.cursorRules}`)
921
+ if (ctx.task?.readmeMarkdown?.trim()) parts.push(`\n## Implementation Plan\n\n${ctx.task.readmeMarkdown}`)
922
+ if (ctx.task?.scoutReport?.trim()) parts.push(`\n## Scout Report (Codebase Analysis)\n\n${ctx.task.scoutReport}`)
923
+ if (ctx.task?.decomposition?.trim()) {
924
+ try {
925
+ const dec = JSON.parse(ctx.task.decomposition)
926
+ parts.push(`\n## Execution Plan\n\nExecution order: ${JSON.stringify(dec.executionOrder)}\nFile ownership: ${JSON.stringify(dec.fileOwnershipMap, null, 2)}`)
927
+ } catch { /* non-fatal */ }
928
+ }
929
+
930
+ return text({
931
+ role: effectiveRole,
932
+ taskKey: ctx.taskKey,
933
+ taskTitle: ctx.taskTitle,
934
+ systemPrompt: parts.join('\n\n'),
935
+ allowedTools: ctx.customAllowedTools || null,
936
+ claimedFiles: ctx.task?.claimedFiles || [],
937
+ warnings: ctx.warnings || [],
938
+ task: ctx.task,
939
+ project: ctx.project,
940
+ usage: 'Use systemPrompt as your Claude system prompt. If allowedTools is set, restrict your MCP tool calls to that list.',
571
941
  })
572
942
  }
573
943
  )
@@ -579,7 +949,17 @@ Set confirmed=false first to read everything, then confirmed=true to execute.`,
579
949
 
580
950
  Returns the diff file-by-file (filename, additions, deletions, patch hunks), the task's README spec, acceptance criteria, and CI check results so you can verify code against the plan.
581
951
 
582
- Call this FIRST before post_pr_review. Use it when the developer asks "review PR", "check the PR for TASK-X", or "is this PR ready to merge".`,
952
+ MANDATORY: After calling this tool, you MUST:
953
+ 1. Read every file in diff.files — check the patch hunk line by line
954
+ 2. Compare each change against spec.implementationPlan — does the code implement what was planned?
955
+ 3. Identify what is present, what is missing, what is wrong
956
+ 4. Build analysisPoints (min 2) referencing specific filenames and function names
957
+ 5. Only THEN call post_pr_review with your verdict and analysisPoints
958
+
959
+ Do NOT call post_pr_review immediately after this without analyzing the diff first.
960
+ Do NOT approve a PR without reading the patch hunks.
961
+
962
+ Use this when the developer asks "review PR", "check the PR for TASK-X", or "is this PR ready to merge".`,
583
963
  {
584
964
  taskId: z.string().describe("Task's MongoDB ObjectId (used to find the project + PR number)"),
585
965
  },
@@ -654,48 +1034,75 @@ Call this FIRST before post_pr_review. Use it when the developer asks "review PR
654
1034
  // ── post_pr_review ────────────────────────────────────────────────────────────
655
1035
  server.tool(
656
1036
  'post_pr_review',
657
- `Post a GitHub PR review after analyzing the code diff.
1037
+ `Post a GitHub PR review after analyzing the code diff from review_pr.
658
1038
 
659
1039
  Supports three actions:
660
1040
  - APPROVE — Approves the PR. Notifies the developer they can merge.
661
1041
  - REQUEST_CHANGES — Blocks merge. Developer gets notified. Task board updated.
662
1042
  - COMMENT — Leaves a comment without approving or blocking.
663
1043
 
664
- Always call review_pr first to get the diff and spec context. Then call this with your verdict and reasoning.
1044
+ REQUIRED WORKFLOW — you MUST call review_pr first and read the diff before calling this.
1045
+ You MUST populate analysisPoints with specific findings from the diff — what you checked,
1046
+ what matched the spec, what is missing or broken. Generic analysis like "code looks good"
1047
+ will be rejected. Be file-specific: reference filenames, function names, line numbers.
665
1048
 
666
- IMPORTANT: This posts a real GitHub review and updates the InternalTool board. The human must confirm before this runs — always show the review body to the user and ask for approval before calling with confirmed=true.`,
1049
+ Human approval gate: confirmed=false shows preview, confirmed=true posts to GitHub.
1050
+
1051
+ Returns reviewId — save it and pass it to merge_pr to prove semantic review happened.`,
667
1052
  {
668
- taskId: z.string().describe("Task's MongoDB ObjectId"),
669
- event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe('Review action'),
670
- body: z.string().describe('Review summary — be specific: what was good, what needs fixing, which files/lines'),
671
- confirmed: z.boolean().optional().default(false).describe('Set true only after the human has read and approved the review text'),
1053
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
1054
+ event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe('Review action'),
1055
+ body: z.string().describe('Review summary posted to GitHub — be specific about files, functions, issues'),
1056
+ analysisPoints: z.array(z.string()).min(2).describe(
1057
+ 'Structured findings from your diff analysis. Each entry must reference a specific file, function, or line. ' +
1058
+ 'Examples: ["calculator.js: add/subtract/multiply all present and correct", ' +
1059
+ '"divide() function missing — spec requires divide-by-zero handling", ' +
1060
+ '"No input validation on any function — null/string inputs silently coerce"]. ' +
1061
+ 'Minimum 2 points required. Generic entries like "code looks fine" are rejected.'
1062
+ ),
1063
+ confirmed: z.boolean().optional().default(false).describe('Set true after the human has read and approved the review text'),
672
1064
  },
673
- async ({ taskId, event, body: reviewBody, confirmed = false }) => {
1065
+ async ({ taskId, event, body: reviewBody, analysisPoints, confirmed = false }) => {
674
1066
  const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
675
1067
  if (!taskRes?.success) return errorText('Task not found')
676
1068
  const task = taskRes.data.task
677
1069
 
678
1070
  const prNumber = task.github?.prNumber
679
1071
  const projectId = task.project?._id || task.project
680
- if (!prNumber) return errorText('No PR linked to this task.')
1072
+ if (!prNumber) return errorText('No PR linked to this task. Call raise_pr first.')
681
1073
 
682
- // Always preview firsthuman must confirm
1074
+ // Enforce that analysisPoints are specific reject obviously generic ones
1075
+ const genericPhrases = ['looks good', 'lgtm', 'code is fine', 'no issues', 'all good', 'seems correct']
1076
+ const tooGeneric = analysisPoints.filter(p =>
1077
+ p.trim().length < 20 || genericPhrases.some(g => p.toLowerCase().includes(g))
1078
+ )
1079
+ if (tooGeneric.length > 0) {
1080
+ return text({
1081
+ blocked: true,
1082
+ reason: 'analysisPoints must be file-specific and reference actual diff content.',
1083
+ rejected: tooGeneric,
1084
+ instruction: 'Call review_pr first to get the diff, then re-examine each file and describe specific findings.',
1085
+ })
1086
+ }
1087
+
1088
+ // Preview — human must confirm
683
1089
  if (!confirmed) {
684
1090
  return text({
685
1091
  preview: {
686
- action: event,
1092
+ action: event,
687
1093
  prNumber,
688
- taskKey: task.key,
689
- taskTitle: task.title,
1094
+ taskKey: task.key,
1095
+ taskTitle: task.title,
690
1096
  reviewBody,
1097
+ analysisPoints,
691
1098
  },
692
1099
  warning: event === 'APPROVE'
693
1100
  ? '✅ This will APPROVE the PR on GitHub and notify the developer they can merge.'
694
1101
  : event === 'REQUEST_CHANGES'
695
- ? '⚠️ This will REQUEST CHANGES — the developer will be blocked from merging until they fix the issues and you re-approve.'
1102
+ ? '⚠️ This will REQUEST CHANGES — the developer will be blocked from merging until they fix the issues.'
696
1103
  : 'ℹ️ This will post a comment on the PR without approving or blocking.',
697
1104
  requiresConfirmation: true,
698
- message: `Show the review body above to the user and ask them to confirm. Then call post_pr_review again with confirmed=true.`,
1105
+ 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.',
699
1106
  })
700
1107
  }
701
1108
 
@@ -705,20 +1112,24 @@ IMPORTANT: This posts a real GitHub review and updates the InternalTool board. T
705
1112
  )
706
1113
  if (!res?.success) return errorText(res?.message || 'Could not post review')
707
1114
 
1115
+ const reviewId = res.data?.reviewId
1116
+
708
1117
  return text({
709
- posted: true,
1118
+ posted: true,
710
1119
  event,
711
1120
  prNumber,
712
- taskKey: task.key,
713
- message: event === 'APPROVE'
1121
+ reviewId,
1122
+ taskKey: task.key,
1123
+ analysisPoints,
1124
+ message: event === 'APPROVE'
714
1125
  ? `✅ PR #${prNumber} approved. Developer has been notified and can now merge.`
715
1126
  : event === 'REQUEST_CHANGES'
716
1127
  ? `⚠️ Changes requested on PR #${prNumber}. Developer has been notified. Task board updated.`
717
1128
  : `💬 Comment posted on PR #${prNumber}.`,
718
1129
  nextStep: event === 'APPROVE'
719
- ? `Call merge_pr with taskId="${taskId}" to merge, or wait for the developer to merge.`
1130
+ ? `Call merge_pr with taskId="${taskId}" and reviewId="${reviewId}" to merge.`
720
1131
  : event === 'REQUEST_CHANGES'
721
- ? `Wait for the developer to push fixes. They'll call fix_pr_feedback, then re-push. You'll get notified.`
1132
+ ? `Wait for the developer to push fixes. They'll call fix_pr_feedback, then re-push. You'll be notified.`
722
1133
  : null,
723
1134
  })
724
1135
  }
@@ -727,28 +1138,35 @@ IMPORTANT: This posts a real GitHub review and updates the InternalTool board. T
727
1138
  // ── merge_pr ─────────────────────────────────────────────────────────────────
728
1139
  server.tool(
729
1140
  'merge_pr',
730
- `Merge a pull request after human approval. Runs safety checks before executing.
1141
+ `Merge a pull request after semantic review and human approval.
1142
+
1143
+ REQUIRED WORKFLOW:
1144
+ 1. Call review_pr — read the diff and spec
1145
+ 2. Call post_pr_review — post your verdict with specific analysisPoints, get back reviewId
1146
+ 3. Call merge_pr — pass reviewId to prove semantic review happened
1147
+
1148
+ If the PR already has GitHub approvals from before this session, reviewId is not required.
731
1149
 
732
1150
  Safety checks (pre-merge):
1151
+ - Semantic review: reviewId required if PR has no existing GitHub approvals
733
1152
  - PR must be open and not already merged
734
1153
  - No pending change requests
735
1154
  - CI checks must be passing (pass skipChecks=true to override)
736
1155
 
737
1156
  Human approval gate:
738
- - confirmed=false (default): shows safety check results and asks for confirmation
1157
+ - confirmed=false (default): shows all checks and asks for confirmation
739
1158
  - confirmed=true: executes the merge
740
1159
 
741
- The task moves to Done automatically via the existing GitHub webhook. A final comment is posted on the task.
742
-
743
- Use this when the reviewer says "merge it", "looks good, ship it", or "merge the PR for TASK-X".`,
1160
+ The task moves to Done automatically via the GitHub webhook.`,
744
1161
  {
745
1162
  taskId: z.string().describe("Task's MongoDB ObjectId"),
746
1163
  confirmed: z.boolean().optional().default(false).describe('Set true only after the human has reviewed the safety checks and approved the merge'),
747
1164
  mergeMethod: z.enum(['squash', 'merge', 'rebase']).optional().default('squash').describe('Merge strategy (default: squash)'),
748
1165
  commitTitle: z.string().optional().describe('Override the merge commit title. Defaults to PR title (#number).'),
749
1166
  skipChecks: z.boolean().optional().default(false).describe('Skip CI check gate — use only when checks are informational or flaky'),
1167
+ 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.'),
750
1168
  },
751
- async ({ taskId, confirmed = false, mergeMethod = 'squash', commitTitle, skipChecks = false }) => {
1169
+ async ({ taskId, confirmed = false, mergeMethod = 'squash', commitTitle, skipChecks = false, reviewId }) => {
752
1170
  const taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`))
753
1171
  if (!taskRes?.success) return errorText('Task not found')
754
1172
  const task = taskRes.data.task
@@ -769,8 +1187,30 @@ Use this when the reviewer says "merge it", "looks good, ship it", or "merge the
769
1187
  return text({ blocked: true, reason: `PR #${prNumber} is ${pr.state} — cannot merge a closed PR.` })
770
1188
  }
771
1189
 
1190
+ // ── Semantic review gate ──────────────────────────────────────────────────
1191
+ // If the PR has no GitHub approvals from any session, a reviewId from
1192
+ // post_pr_review in this session is mandatory. This ensures code was
1193
+ // actually read and analyzed before merge, not just rubber-stamped.
1194
+ const hasExistingApproval = pr.approvals > 0
1195
+ const hasSessionReview = !!reviewId
1196
+ if (!hasExistingApproval && !hasSessionReview) {
1197
+ return text({
1198
+ blocked: true,
1199
+ reason: 'No semantic review has been performed on this PR.',
1200
+ required: [
1201
+ `1. Call review_pr with taskId="${taskId}" to read the diff and spec`,
1202
+ `2. Analyze the diff — check every file against the implementation plan`,
1203
+ `3. Call post_pr_review with taskId="${taskId}", your verdict, and specific analysisPoints`,
1204
+ `4. Pass the returned reviewId here to prove the review happened`,
1205
+ ],
1206
+ note: 'If this PR was already approved by a reviewer on GitHub, merge_pr will allow it through automatically.',
1207
+ })
1208
+ }
1209
+
772
1210
  // Build safety check summary
773
1211
  const safetyChecks = [
1212
+ { check: 'Semantic review performed', pass: hasExistingApproval || hasSessionReview,
1213
+ note: hasSessionReview ? `reviewId ${reviewId} from this session` : hasExistingApproval ? `${pr.approvals} existing GitHub approval(s)` : 'Missing — call review_pr then post_pr_review' },
774
1214
  { check: 'PR is open', pass: pr.state === 'open' },
775
1215
  { check: 'Not already merged', pass: !pr.merged },
776
1216
  { check: 'No change requests', pass: pr.changesRequested === 0,
@@ -858,8 +1298,10 @@ Flow:
858
1298
  confirmed: z.boolean().optional().default(false).describe('Set true after reviewing the context to execute the resume'),
859
1299
  repoPath: z.string().optional().describe('Absolute path to the local git repo. Defaults to MCP process working directory.'),
860
1300
  autoStash: z.boolean().optional().default(false).describe('If working tree is dirty, auto-stash before switching. Labelled with task key.'),
1301
+ agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
1302
+ .describe('Set the agent role for this resumed session. Role-specific constraints are injected into cursor rules.'),
861
1303
  },
862
- async ({ taskId, confirmed = false, repoPath, autoStash = false }) => {
1304
+ async ({ taskId, confirmed = false, repoPath, autoStash = false, agentRole }) => {
863
1305
  // ── Fetch task ────────────────────────────────────────────────────────
864
1306
  let taskRes
865
1307
  try { taskRes = await apiWithRetry(() => api.get(`/api/tasks/${taskId}`)) }
@@ -1017,10 +1459,21 @@ Flow:
1017
1459
  }
1018
1460
  }
1019
1461
 
1020
- // Restore cursor rules
1462
+ // Restore cursor rules (with role injection if set)
1021
1463
  let cursorRulesFile = null
1022
- if (task?.cursorRules?.trim() && repoRoot) {
1023
- cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
1464
+ const hasCursorRulesResume = task?.cursorRules?.trim()
1465
+ if ((hasCursorRulesResume || agentRole) && repoRoot) {
1466
+ cursorRulesFile = writeCursorRulesFile(
1467
+ task.key,
1468
+ hasCursorRulesResume || '(No task-specific rules — follow role constraints above.)',
1469
+ repoPath,
1470
+ agentRole || null
1471
+ )
1472
+ }
1473
+
1474
+ // Persist agentRole to server if set
1475
+ if (agentRole) {
1476
+ api.patch(`/api/tasks/${taskId}`, { agentRole }).catch(() => {/* non-fatal */})
1024
1477
  }
1025
1478
 
1026
1479
  // Last commit metadata
@@ -1028,18 +1481,28 @@ Flow:
1028
1481
 
1029
1482
  return text({
1030
1483
  resumed: true,
1031
- task: { key: task.key, title: task.title, column: task.column },
1484
+ task: { key: task.key, title: task.title, column: task.column, agentRole: agentRole || null },
1032
1485
  git: gitResult || { switched: false, reason: 'No repo found' },
1033
1486
  autoStashed: autoStash && localState === 'modified' && !alreadyOnBranch
1034
1487
  ? `Changes stashed before switch. Run: git stash list to see them.`
1035
1488
  : null,
1036
1489
  lastCommit: lastCommit || null,
1037
1490
  parkNote: task.parkNote || null,
1038
- cursorRules: cursorRulesFile ? { restored: true, path: cursorRulesFile } : { restored: false },
1491
+ cursorRules: cursorRulesFile
1492
+ ? { restored: true, path: cursorRulesFile, agentRole: agentRole || null }
1493
+ : { restored: false },
1039
1494
  message: gitResult?.switched || gitResult?.alreadyOnBranch
1040
- ? `You are on branch "${branch}". Ready to resume coding.`
1495
+ ? `You are on branch "${branch}". Ready to resume coding${agentRole ? ` as ${agentRole}` : ''}.`
1041
1496
  : `Branch switch failed — see git.manualSteps.`,
1042
- nextStep: task.column === 'in_review'
1497
+ nextStep: agentRole === 'builder'
1498
+ ? `BUILDER role active. Call claim_files to lock your files before editing.`
1499
+ : agentRole === 'scout'
1500
+ ? `SCOUT role active. Read-only mode — map the codebase and save findings with update_task(scoutReport=...).`
1501
+ : agentRole === 'coordinator'
1502
+ ? `COORDINATOR role active. Call decompose_task to plan parallel workstreams.`
1503
+ : agentRole === 'reviewer'
1504
+ ? `REVIEWER role active. Call review_pr to start the review chain.`
1505
+ : task.column === 'in_review'
1043
1506
  ? `Task is in review. Check if PR feedback needs addressing — call fix_pr_feedback if needed.`
1044
1507
  : task.parkNote?.remaining
1045
1508
  ? `Remaining: ${task.parkNote.remaining}`
@@ -1047,6 +1510,33 @@ Flow:
1047
1510
  })
1048
1511
  }
1049
1512
  )
1513
+
1514
+ // ── log_session_event ─────────────────────────────────────────────────────────
1515
+ server.tool(
1516
+ 'log_session_event',
1517
+ `Log a skill invocation, subagent start, rule activation, or informational note to the task's session timeline.
1518
+ Call this whenever you:
1519
+ - Invoke a skill (type="skill", name=skill file name e.g. "scout-codebase")
1520
+ - Spawn a subagent (type="subagent", name=subagent name)
1521
+ - Activate a rule (type="rule", name=rule name)
1522
+ - Want to note a significant step (type="info", name=short label)
1523
+ The log is visible in InternalTool under the task's Session tab — gives the human full visibility into what the agent is doing.`,
1524
+ {
1525
+ taskId: z.string().describe("Task's MongoDB ObjectId"),
1526
+ type: z.enum(['skill', 'subagent', 'rule', 'info']).describe('Event type'),
1527
+ name: z.string().describe('Name of the skill / subagent / rule / step'),
1528
+ summary: z.string().optional().default('').describe('Optional one-line description of what this event does'),
1529
+ role: z.string().optional().describe('Active agent role at this point (builder, scout, reviewer, coordinator)'),
1530
+ },
1531
+ async ({ taskId, type, name, summary = '', role }) => {
1532
+ trackTaskActivity(taskId, 'log_session_event', { role, summary: `[${type}] ${name}` })
1533
+ // Also push a dedicated entry with the correct event type
1534
+ api.post(`/api/tasks/${taskId}/session/log`, {
1535
+ type, name, role: role || null, summary,
1536
+ }).catch(() => {})
1537
+ return text({ logged: true, type, name, summary })
1538
+ }
1539
+ )
1050
1540
  }
1051
1541
 
1052
1542
  // ── Standup activity formatter ────────────────────────────────────────────────
@@ -1541,8 +2031,11 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1541
2031
  taskId: z.string().describe("Task's MongoDB ObjectId"),
1542
2032
  confirmed: z.boolean().optional().default(false).describe('Set true after reading the plan to move the task to in_progress'),
1543
2033
  repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory). Used to write cursor rules file.'),
2034
+ agentRole: z.enum(['builder', 'reviewer', 'scout', 'coordinator']).optional()
2035
+ .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.'),
1544
2036
  },
1545
- async ({ taskId, confirmed = false, repoPath }) => {
2037
+ async ({ taskId, confirmed = false, repoPath, agentRole }) => {
2038
+ trackTaskActivity(taskId, 'kickoff_task')
1546
2039
  const taskRes = await api.get(`/api/tasks/${taskId}`)
1547
2040
  if (!taskRes?.success) return errorText('Task not found')
1548
2041
  const task = taskRes.data.task
@@ -1573,6 +2066,7 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1573
2066
  // ── Preview: show the full plan before touching anything ──
1574
2067
  const pendingApv = (task.approvals || []).find(a => a.state === 'pending') || null
1575
2068
  const hasApprovedApv = (task.approvals || []).some(a => a.state === 'approved')
2069
+ const approvalState = pendingApv ? 'pending' : hasApprovedApv ? 'approved' : 'none'
1576
2070
  const approvalBlocks = ['backlog', 'todo'].includes(task.column) && !hasApprovedApv
1577
2071
 
1578
2072
  // Build the next-step roadmap so developer knows exactly what comes after reading the plan
@@ -1722,6 +2216,21 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1722
2216
  ? `⏳ Plan is submitted and awaiting approval — you cannot create a branch until it is approved.`
1723
2217
  : `⚠️ Plan is not yet submitted for approval. Submit it first, then create the branch.`
1724
2218
  : null,
2219
+ decompositionWarning: (() => {
2220
+ if (!task.decomposition?.trim()) return null
2221
+ try {
2222
+ const dec = JSON.parse(task.decomposition)
2223
+ if (!dec.scoutReportPresent && !task.scoutReport?.trim())
2224
+ return `⚠️ This task has a decomposition plan but no scout report. Consider running scout_task before builders start.`
2225
+ return `Decomposition exists: ${dec.totalSubtasks} subtask(s), ${dec.executionOrder?.length || 0} execution group(s).`
2226
+ } catch { return null }
2227
+ })(),
2228
+ agentContext: {
2229
+ hasScoutReport: !!(task.scoutReport?.trim()),
2230
+ hasDecomposition: !!(task.decomposition?.trim()),
2231
+ claimedFiles: task.claimedFiles || [],
2232
+ agentRole: task.agentRole || null,
2233
+ },
1725
2234
  requiresConfirmation: true,
1726
2235
  message: approvalBlocks
1727
2236
  ? `Read the plan above, then follow workflowRoadmap — approval is required before you can branch and start coding.`
@@ -1757,11 +2266,18 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1757
2266
 
1758
2267
  // ── Confirmed: move to in_progress and fetch recent commits ──
1759
2268
  let recentCommits = []
2269
+ let projectAgentConfig = null
1760
2270
  try {
1761
2271
  const commitsRes = await api.get(`/api/projects/${task.project}/github/commits?per_page=10`)
1762
2272
  if (commitsRes?.success) recentCommits = commitsRes.data.commits || []
1763
2273
  } catch { /* GitHub may not be linked */ }
1764
2274
 
2275
+ // Fetch project agentConfig for custom role generation
2276
+ try {
2277
+ const projRes = await api.get(`/api/projects/${task.project}`)
2278
+ if (projRes?.success) projectAgentConfig = projRes.data.project?.agentConfig || null
2279
+ } catch { /* non-fatal */ }
2280
+
1765
2281
  // If the task already has a branch linked, it's safe to move to in_progress now.
1766
2282
  // If not, create_branch will do the move once the branch is created.
1767
2283
  const alreadyHasBranch = !!task.github?.headBranch
@@ -1773,12 +2289,31 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1773
2289
  } catch { /* might already be in_progress */ }
1774
2290
  }
1775
2291
 
1776
- // Write cursor rules file to local repo immediately on kickoff
2292
+ // Write cursor rules file to local repo immediately on kickoff (with role injection if set)
1777
2293
  let cursorRulesFile = null
1778
2294
  if (hasCursorRules) {
1779
- cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath)
2295
+ cursorRulesFile = writeCursorRulesFile(task.key, task.cursorRules, repoPath, agentRole || null)
2296
+ } else if (agentRole) {
2297
+ // Even if no task-specific cursor rules, write role rules alone
2298
+ cursorRulesFile = writeCursorRulesFile(task.key, '(No task-specific rules — follow role constraints above.)', repoPath, agentRole)
1780
2299
  }
1781
2300
 
2301
+ // Dynamically generate .cursor/agents, .cursor/skills, .cursor/commands
2302
+ // based on live task data — so every agent gets the right task ID, files, and role
2303
+ const workspaceResult = writeCursorWorkspace(task, projectAgentConfig, repoPath || process.cwd())
2304
+
2305
+ // Persist agentRole + workspace status to server
2306
+ const workspacePatch = {
2307
+ ...(agentRole ? { agentRole } : {}),
2308
+ agentWorkspace: {
2309
+ kickedOffAt: new Date().toISOString(),
2310
+ clearedAt: null,
2311
+ role: agentRole || task.agentRole || null,
2312
+ files: workspaceResult?.written?.map(f => f.replace(workspaceResult.repoRoot, '').replace(/^\//, '')) || [],
2313
+ },
2314
+ }
2315
+ api.patch(`/api/tasks/${taskId}`, workspacePatch).catch(() => {/* non-fatal */})
2316
+
1782
2317
  return text({
1783
2318
  started: {
1784
2319
  key: task.key,
@@ -1787,14 +2322,26 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1787
2322
  column: moved ? 'in_progress' : task.column,
1788
2323
  branch: task.github?.headBranch || null,
1789
2324
  subtasks,
2325
+ agentRole: agentRole || null,
1790
2326
  },
1791
2327
  implementationPlan: hasReadme ? task.readmeMarkdown : null,
1792
- cursorRules: hasCursorRules
1793
- ? { active: true, rules: task.cursorRules, instruction: '⚠️ CURSOR RULES ACTIVE — You MUST follow every rule in the "rules" field for the entire duration of this task.' }
2328
+ cursorRules: hasCursorRules || agentRole
2329
+ ? {
2330
+ active: true,
2331
+ agentRole: agentRole || null,
2332
+ rules: task.cursorRules || null,
2333
+ roleRules: agentRole ? ROLE_RULES[agentRole] : null,
2334
+ instruction: agentRole
2335
+ ? `⚠️ AGENT ROLE: ${agentRole.toUpperCase()} — Follow the role behavioral constraints injected into the cursor rules file. These override default behavior.`
2336
+ : '⚠️ CURSOR RULES ACTIVE — You MUST follow every rule in the "rules" field for the entire duration of this task.',
2337
+ }
1794
2338
  : { active: false },
1795
2339
  cursorRulesFile: cursorRulesFile
1796
2340
  ? { written: true, path: cursorRulesFile, note: 'Rules file written to your repo. Cursor enforces it automatically on every prompt.' }
1797
- : hasCursorRules ? { written: false, note: 'Could not write rules file — not inside a git repo.' } : null,
2341
+ : (hasCursorRules || agentRole) ? { written: false, note: 'Could not write rules file — not inside a git repo.' } : null,
2342
+ cursorWorkspace: workspaceResult
2343
+ ? { written: true, files: workspaceResult.written.map(f => f.replace(workspaceResult.repoRoot, '')), note: 'Agent, skill, and command files generated dynamically from task data. Reload Cursor Settings → Rules, Skills, Subagents to see them.' }
2344
+ : { written: false, note: 'Could not generate workspace files — not inside a git repo.' },
1798
2345
  recentCommits: recentCommits.slice(0, 5).map(c => ({
1799
2346
  sha: c.sha?.slice(0, 7),
1800
2347
  message: c.commit?.message?.split('\n')[0],
@@ -1803,7 +2350,15 @@ Use this when a developer says "start task", "brief me on", or "what do I need t
1803
2350
  })),
1804
2351
  movedToInProgress: moved,
1805
2352
  suggestedBranch: alreadyHasBranch ? null : suggestedBranch,
1806
- nextStep: alreadyHasBranch
2353
+ nextStep: agentRole === 'scout'
2354
+ ? `You are in SCOUT mode. Read the codebase, then save your findings with update_task(scoutReport=...). Do NOT modify any files.`
2355
+ : agentRole === 'coordinator'
2356
+ ? `You are in COORDINATOR mode. Read the README, then call decompose_task to break the work into parallel subtasks with file ownership.`
2357
+ : agentRole === 'builder'
2358
+ ? `You are in BUILDER mode. Call claim_files first, then start coding on "${alreadyHasBranch ? task.github.headBranch : suggestedBranch}".`
2359
+ : agentRole === 'reviewer'
2360
+ ? `You are in REVIEWER mode. Call review_pr to get the diff, then post_pr_review with your analysis.`
2361
+ : alreadyHasBranch
1807
2362
  ? `Branch "${task.github.headBranch}" already exists. Task is now In progress — start coding.`
1808
2363
  : `Call create_branch to create "${suggestedBranch}" — it will check your local git state and move the task to In progress automatically.`,
1809
2364
  })
@@ -2210,15 +2765,118 @@ function findRepoRoot(startPath) {
2210
2765
  return null
2211
2766
  }
2212
2767
 
2213
- /** Write task-specific cursor rules to .cursor/rules/<taskKey>.mdc in the local repo root. */
2214
- function writeCursorRulesFile(taskKey, rulesMarkdown, startPath) {
2768
+ // ── Agent role behavioral rule templates ─────────────────────────────────────
2769
+ // Prepended to cursor rules when an agentRole is set. Defines what the agent
2770
+ // CAN and CANNOT do for the duration of the task session.
2771
+ const ROLE_RULES = {
2772
+ builder: `## Agent Role: BUILDER
2773
+
2774
+ You are a BUILDER agent. Your behavioral constraints for this session:
2775
+
2776
+ **ALLOWED:**
2777
+ - Write, modify, and delete code files within the task scope
2778
+ - Create new files required by the implementation plan
2779
+ - Commit and push changes on the task branch
2780
+ - Run tests and fix failures
2781
+ - Claim file ownership before editing (use claim_files MCP tool)
2782
+
2783
+ **NOT ALLOWED:**
2784
+ - Modify files owned by another in-progress task (check claimedFiles conflicts)
2785
+ - Make architectural decisions not in the implementation plan — flag them instead
2786
+ - Edit files outside the task scope without explicit approval
2787
+ - Merge to main/master/dev directly
2788
+
2789
+ **WORK STYLE:**
2790
+ - Read the implementation plan fully before writing any code
2791
+ - Claim your files at the start with claim_files
2792
+ - Follow the spec precisely — don't add unrequested features
2793
+ - Commit atomically with conventional commit format (feat/fix/refactor)`,
2794
+
2795
+ scout: `## Agent Role: SCOUT
2796
+
2797
+ You are a SCOUT agent. Your behavioral constraints for this session:
2798
+
2799
+ **ALLOWED:**
2800
+ - Read and analyze any file in the codebase
2801
+ - Search for patterns, dependencies, and potential conflicts
2802
+ - Write a structured scout report (update_task with scoutReport field)
2803
+ - Identify which files the implementation will need to touch
2804
+ - Surface risks, gotchas, and architectural considerations
2805
+
2806
+ **NOT ALLOWED:**
2807
+ - Modify any source code files
2808
+ - Create branches or commits
2809
+ - Move tasks or create PRs
2810
+ - Make changes that would require a PR
2811
+
2812
+ **WORK STYLE:**
2813
+ - Your output is a scout report for the builder agents
2814
+ - Map file dependencies before builders start
2815
+ - Identify conflict risks with other in-progress tasks
2816
+ - Estimate complexity and flag potential blockers
2817
+ - Save your report with update_task(scoutReport=...)`,
2818
+
2819
+ reviewer: `## Agent Role: REVIEWER
2820
+
2821
+ You are a REVIEWER agent. Your behavioral constraints for this session:
2822
+
2823
+ **ALLOWED:**
2824
+ - Read all code changes and diffs (use review_pr)
2825
+ - Post structured reviews with specific analysis points (use post_pr_review)
2826
+ - Request changes or approve the PR
2827
+ - Reference specific files, functions, and line numbers in your review
2828
+
2829
+ **NOT ALLOWED:**
2830
+ - Approve a PR without reading every file in the diff
2831
+ - Write "looks good" or generic analysis — be specific and file-based
2832
+ - Merge the PR without completing the review chain (review_pr → post_pr_review → merge_pr)
2833
+ - Modify source code directly
2834
+
2835
+ **WORK STYLE:**
2836
+ - Always call review_pr first to get the full diff and spec
2837
+ - Check each file against the implementation plan
2838
+ - Build analysisPoints with file-specific findings (minimum 2)
2839
+ - Reference function names and line numbers
2840
+ - Only call merge_pr after post_pr_review returns a reviewId`,
2841
+
2842
+ coordinator: `## Agent Role: COORDINATOR
2843
+
2844
+ You are a COORDINATOR agent. Your behavioral constraints for this session:
2845
+
2846
+ **ALLOWED:**
2847
+ - Read the task implementation plan and codebase structure
2848
+ - Decompose the plan into parallel subtasks (use decompose_task)
2849
+ - Assign file ownership to each subtask with no overlaps
2850
+ - Assign roles to each subtask (builder, scout, reviewer)
2851
+ - Create subtasks on the board (use update_task with subtasks)
2852
+ - Monitor progress and resolve file conflicts
2853
+
2854
+ **NOT ALLOWED:**
2855
+ - Write implementation code directly
2856
+ - Claim file ownership for yourself
2857
+ - Bypass the decomposition step — always decompose before builders start
2858
+ - Start coding without a scout report when the codebase is unfamiliar
2859
+
2860
+ **WORK STYLE:**
2861
+ - Start with decompose_task to get the structured execution plan
2862
+ - Ensure no two builder subtasks claim the same file
2863
+ - Scout report must exist before builders begin
2864
+ - Each builder subtask must have explicit file ownership and a clear scope`,
2865
+ }
2866
+
2867
+ /** Write task-specific cursor rules to .cursor/rules/<taskKey>.mdc in the local repo root.
2868
+ * When role is provided, role-specific behavioral constraints are prepended. */
2869
+ function writeCursorRulesFile(taskKey, rulesMarkdown, startPath, role = null) {
2215
2870
  try {
2216
2871
  const repoRoot = findRepoRoot(startPath)
2217
2872
  if (!repoRoot) return null
2218
2873
  const rulesDir = join(repoRoot, '.cursor', 'rules')
2219
2874
  mkdirSync(rulesDir, { recursive: true })
2220
2875
  const filePath = join(rulesDir, `${taskKey.toLowerCase()}.mdc`)
2221
- const content = `---\ndescription: Task-specific rules for ${taskKey} — auto-generated by InternalTool MCP. Do not edit manually.\nalwaysApply: true\n---\n\n${rulesMarkdown}\n`
2876
+ const roleSection = role && ROLE_RULES[role]
2877
+ ? `${ROLE_RULES[role]}\n\n---\n\n## Task-Specific Rules\n\n`
2878
+ : ''
2879
+ 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`
2222
2880
  writeFileSync(filePath, content, 'utf8')
2223
2881
  return filePath
2224
2882
  } catch {
@@ -2226,6 +2884,202 @@ function writeCursorRulesFile(taskKey, rulesMarkdown, startPath) {
2226
2884
  }
2227
2885
  }
2228
2886
 
2887
+ /**
2888
+ * Dynamically generate .cursor/ workspace files from live InternalTool data.
2889
+ *
2890
+ * Sources (in priority order):
2891
+ * 1. project.agentConfig.subagents[] → .cursor/agents/<name>.md
2892
+ * 2. project.agentConfig.skills[] → .cursor/skills/<name>.md
2893
+ * 3. project.agentConfig.rules[] → .cursor/rules/<name>.mdc (project-level, alwaysApply)
2894
+ * 4. Built-in ROLE_RULES / default skill templates (fallback when DB arrays are empty)
2895
+ *
2896
+ * Task-specific agent file (.cursor/agents/active-agent.md) is always written
2897
+ * from the task's role + claimed files + task ID so Cursor knows exactly what to do.
2898
+ * It is deleted by deleteCursorWorkspace() when the task is parked or completed.
2899
+ */
2900
+ function writeCursorWorkspace(task, projectAgentConfig, startPath) {
2901
+ try {
2902
+ const repoRoot = findRepoRoot(startPath)
2903
+ if (!repoRoot) return null
2904
+
2905
+ const role = task.agentRole || null
2906
+ const taskId = String(task._id)
2907
+ const taskKey = task.key || 'TASK-???'
2908
+ const taskTitle = task.title || ''
2909
+ const claimedFiles = task.claimedFiles || []
2910
+ const hasScout = !!(task.scoutReport?.trim())
2911
+ const hasDec = !!(task.decomposition?.trim())
2912
+ const cfg = projectAgentConfig || {}
2913
+
2914
+ const agentsDir = join(repoRoot, '.cursor', 'agents')
2915
+ const skillsDir = join(repoRoot, '.cursor', 'skills')
2916
+ const commandsDir = join(repoRoot, '.cursor', 'commands')
2917
+ const rulesDir = join(repoRoot, '.cursor', 'rules')
2918
+ mkdirSync(agentsDir, { recursive: true })
2919
+ mkdirSync(skillsDir, { recursive: true })
2920
+ mkdirSync(commandsDir, { recursive: true })
2921
+ mkdirSync(rulesDir, { recursive: true })
2922
+
2923
+ const written = []
2924
+
2925
+ // ── 1. Project-level rules from DB → .cursor/rules/<name>.mdc ─────────────
2926
+ const projectRules = cfg.rules || []
2927
+ for (const r of projectRules) {
2928
+ if (!r.name || !r.body) continue
2929
+ const content = `---\ndescription: ${r.description || r.name} — project rule managed in InternalTool. Do not edit manually.\nalwaysApply: ${r.alwaysApply ? 'true' : 'false'}\n---\n\n${r.body}\n`
2930
+ const f = join(rulesDir, `${r.name}.mdc`)
2931
+ writeFileSync(f, content, 'utf8')
2932
+ written.push(f)
2933
+ }
2934
+
2935
+ // ── 2. Subagents from DB → .cursor/agents/<name>.md ───────────────────────
2936
+ // If DB has subagents defined, write them verbatim (admin-authored markdown)
2937
+ const dbSubagents = cfg.subagents || []
2938
+ for (const s of dbSubagents) {
2939
+ if (!s.name || !s.body) continue
2940
+ const header = `---\nname: ${s.name}\ndescription: ${s.description || s.name}\n---\n\n`
2941
+ const f = join(agentsDir, `${s.name}.md`)
2942
+ writeFileSync(f, header + s.body, 'utf8')
2943
+ written.push(f)
2944
+ }
2945
+
2946
+ // ── 3. Task-specific active-agent.md — always written, deleted on done ────
2947
+ // Uses DB subagent body as template if one matches the role; falls back to
2948
+ // built-in ROLE_RULES template with task-specific data injected.
2949
+ if (role) {
2950
+ const dbMatch = dbSubagents.find(s => s.name === `${role}-agent` || s.name === role)
2951
+ let agentBody
2952
+ if (dbMatch?.body) {
2953
+ // Admin wrote a custom body — inject task data into placeholder tokens
2954
+ agentBody = dbMatch.body
2955
+ .replace(/\{\{taskId\}\}/g, taskId)
2956
+ .replace(/\{\{taskKey\}\}/g, taskKey)
2957
+ .replace(/\{\{taskTitle\}\}/g, taskTitle)
2958
+ .replace(/\{\{claimedFiles\}\}/g, claimedFiles.map(f => `- \`${f}\``).join('\n') || '(none)')
2959
+ } else {
2960
+ // Built-in fallback template
2961
+ const fileLine = claimedFiles.length
2962
+ ? claimedFiles.map(f => `- \`${f}\``).join('\n')
2963
+ : '- (no files claimed yet — call claim_files first)'
2964
+ const scoutLine = hasScout ? '✅ Scout report available — call `get_agent_context` to read it.' : '⚠️ No scout report yet.'
2965
+ const decLine = hasDec ? '✅ Execution plan exists — follow the decomposition order.' : ''
2966
+
2967
+ const BUILT_IN = {
2968
+ scout: `# Scout Agent — ${taskKey}\n\n**Task ID:** \`${taskId}\`\n**Task:** ${taskTitle}\n\n## Constraints\n- READ ONLY — do not write or modify any file\n- You CAN read files, run tests, and use MCP tools\n\n## Workflow\n1. Call \`get_agent_context\` with taskId \`${taskId}\`\n2. Read every source file systematically\n3. Call \`scout_task\` with confirmed=false, then confirmed=true + your report\n`,
2969
+ builder: `# Builder Agent — ${taskKey}\n\n**Task ID:** \`${taskId}\`\n**Task:** ${taskTitle}\n\n**Claimed files:**\n${fileLine}\n\n${scoutLine}\n${decLine}\n\n## Constraints\n- Only modify your claimed files\n- Every change needs a test update\n- Run tests before marking done\n\n## Workflow\n1. Call \`get_agent_context\` with taskId \`${taskId}\`\n2. Implement the feature\n3. Use the \`run-tests\` skill to verify\n4. Call \`commit_helper\` then \`raise_pr\`\n`,
2970
+ coordinator: `# Coordinator Agent — ${taskKey}\n\n**Task ID:** \`${taskId}\`\n**Task:** ${taskTitle}\n\n## Constraints\n- Do NOT write code — plan and delegate only\n- Each subtask must have exclusive file ownership\n\n## Workflow\n1. Call \`get_agent_context\` with taskId \`${taskId}\`\n2. Break work into subtasks with role + files + description\n3. Call \`decompose_task\` with taskId \`${taskId}\` to save the plan\n`,
2971
+ reviewer: `# Reviewer Agent — ${taskKey}\n\n**Task ID:** \`${taskId}\`\n**Task:** ${taskTitle}\n\n## Constraints\n- Read the full diff before approving\n- Post specific, file-level comments\n\n## Workflow\n1. Call \`get_agent_context\` with taskId \`${taskId}\`\n2. Call \`review_pr\` to fetch the diff\n3. Call \`post_pr_review\` with your verdict\n`,
2972
+ }
2973
+ agentBody = BUILT_IN[role] || `# ${role} Agent — ${taskKey}\n\n**Task ID:** \`${taskId}\`\n**Task:** ${taskTitle}\n`
2974
+ }
2975
+
2976
+ const activeAgentContent = `---\nname: active-agent\ndescription: ${role} agent for ${taskKey} — auto-generated by InternalTool MCP on kickoff. Deleted when task is parked or done.\n---\n\n${agentBody}`
2977
+ writeFileSync(join(agentsDir, 'active-agent.md'), activeAgentContent, 'utf8')
2978
+ written.push(join(agentsDir, 'active-agent.md'))
2979
+ }
2980
+
2981
+ // ── 4. Custom role agents from project.agentConfig.roles (non-subagent) ───
2982
+ for (const cr of (cfg.roles || [])) {
2983
+ if (!cr.name || dbSubagents.some(s => s.name === `${cr.name}-agent`)) continue
2984
+ const content = `---\nname: ${cr.name}-agent\ndescription: ${cr.label || cr.name}${cr.promptHint ? ' — ' + cr.promptHint.slice(0, 80) : ''}\n---\n\n# ${cr.label || cr.name} Agent\n\n**Role:** \`${cr.name}\`\n${cr.promptHint ? `\n## Instructions\n${cr.promptHint}\n` : ''}${cr.allowedTools?.length ? `\n## Allowed tools\n${cr.allowedTools.map(t => `- ${t}`).join('\n')}\n` : ''}`
2985
+ const f = join(agentsDir, `${cr.name}-agent.md`)
2986
+ writeFileSync(f, content, 'utf8')
2987
+ written.push(f)
2988
+ }
2989
+
2990
+ // ── 5. Skills from DB → .cursor/skills/<name>.md ──────────────────────────
2991
+ // If DB is empty, write built-in defaults so Cursor always has something useful.
2992
+ const dbSkills = cfg.skills || []
2993
+ if (dbSkills.length > 0) {
2994
+ for (const s of dbSkills) {
2995
+ if (!s.name || !s.body) continue
2996
+ const header = `---\nname: ${s.name}\ndescription: ${s.description || s.name}\n---\n\n`
2997
+ const f = join(skillsDir, `${s.name}.md`)
2998
+ writeFileSync(f, header + s.body, 'utf8')
2999
+ written.push(f)
3000
+ }
3001
+ } else {
3002
+ // Built-in default skills
3003
+ const defaults = [
3004
+ {
3005
+ name: 'scout-codebase',
3006
+ description: 'Read every source file and save a scout report to InternalTool.',
3007
+ body: `1. Read all source files: routes, models, middleware, utils, tests, README, .cursorrules\n2. Write a report: Auth Flow, Data Model, API Surface, Utilities, Test Coverage, Gaps & Risks\n3. Call \`scout_task\` with taskId \`${taskId}\` confirmed=false (preview), then confirmed=true + report\n\n**READ ONLY — do not modify any file.**`,
3008
+ },
3009
+ {
3010
+ name: 'run-tests',
3011
+ description: 'Run the test suite and fix any failures before marking done.',
3012
+ body: `1. Run \`npm test\`\n2. Report: passed / failed / skipped\n3. For each failure: identify root cause, fix source (not the test)\n4. Re-run until 0 failures\n5. Do NOT mark done until tests are green`,
3013
+ },
3014
+ ]
3015
+ for (const s of defaults) {
3016
+ const content = `---\nname: ${s.name}\ndescription: ${s.description}\n---\n\n${s.body}\n`
3017
+ const f = join(skillsDir, `${s.name}.md`)
3018
+ writeFileSync(f, content, 'utf8')
3019
+ written.push(f)
3020
+ }
3021
+ }
3022
+
3023
+ // ── 6. Command — start-task pre-filled with this task ─────────────────────
3024
+ const startCmd = `---\nname: start-task\ndescription: Start a task from InternalTool — brief, role rules, moves to In Progress.\n---\n\nDefault task: \`${taskId}\` (${taskKey} — ${taskTitle})\n\n1. Ask the user to confirm the task ID or use the default above\n2. Call \`kickoff_task\` with confirmed=false to show the brief\n3. Present: role, description, claimedFiles, cursorRules\n4. Ask "Ready to confirm and move to In Progress?"\n5. If yes, call \`kickoff_task\` again with confirmed=true\n`
3025
+ writeFileSync(join(commandsDir, 'start-task.md'), startCmd, 'utf8')
3026
+ written.push(join(commandsDir, 'start-task.md'))
3027
+
3028
+ return { repoRoot, written }
3029
+ } catch {
3030
+ return null
3031
+ }
3032
+ }
3033
+
3034
+ /**
3035
+ * Delete task-specific workspace files written at kickoff.
3036
+ * Project-level rules (.cursor/rules/<project-rule>.mdc) and skills are kept.
3037
+ * Only the active-agent and start-task command are removed (they are task-scoped).
3038
+ */
3039
+ function deleteCursorWorkspace(role, startPath) {
3040
+ const deleted = []
3041
+ try {
3042
+ const repoRoot = findRepoRoot(startPath)
3043
+ if (!repoRoot) return deleted
3044
+ const toDelete = [
3045
+ join(repoRoot, '.cursor', 'agents', 'active-agent.md'),
3046
+ join(repoRoot, '.cursor', 'commands', 'start-task.md'),
3047
+ ]
3048
+ for (const f of toDelete) {
3049
+ try {
3050
+ if (existsSync(f)) { unlinkSync(f); deleted.push(f) }
3051
+ } catch { /* non-fatal */ }
3052
+ }
3053
+ } catch { /* non-fatal */ }
3054
+ return deleted
3055
+ }
3056
+
3057
+ /**
3058
+ * Record that a specific MCP tool was called for a task.
3059
+ * 1. Updates agentWorkspace.lastActivityAt + lastTool (fast summary for overview card)
3060
+ * 2. Appends to agentSessionLog (the ordered call history shown in Session tab)
3061
+ * Fire-and-forget: never blocks the tool response.
3062
+ */
3063
+ function trackTaskActivity(taskId, toolName, opts = {}) {
3064
+ if (!taskId) return
3065
+ // Fast summary update
3066
+ api.patch(`/api/tasks/${taskId}`, {
3067
+ agentWorkspace: {
3068
+ lastActivityAt: new Date().toISOString(),
3069
+ lastTool: toolName,
3070
+ _incCallCount: true,
3071
+ ...(opts.role ? { role: opts.role } : {}),
3072
+ },
3073
+ }).catch(() => {})
3074
+ // Ordered session log entry
3075
+ api.post(`/api/tasks/${taskId}/session/log`, {
3076
+ type: 'tool',
3077
+ name: toolName,
3078
+ role: opts.role || null,
3079
+ summary: opts.summary || '',
3080
+ }).catch(() => {})
3081
+ }
3082
+
2229
3083
  /** Delete the task-specific cursor rules file when work is complete. */
2230
3084
  function deleteCursorRulesFile(taskKey, startPath) {
2231
3085
  try {
@@ -2680,6 +3534,7 @@ Use this when returning to a task after a break, or when Claude needs the full p
2680
3534
  repoPath: z.string().optional().describe('Absolute path to the local git repo (defaults to MCP process working directory)'),
2681
3535
  },
2682
3536
  async ({ taskId, repoPath }) => {
3537
+ trackTaskActivity(taskId, 'get_task_context')
2683
3538
  let taskRes, activityRes
2684
3539
  try {
2685
3540
  [taskRes, activityRes] = await Promise.all([
@@ -2816,7 +3671,9 @@ If you have uncommitted tracked changes, it will tell you exactly what to do bef
2816
3671
  // ── Approval gate check ───────────────────────────────────────────────────
2817
3672
  // The server blocks non-admins from moving todo → in_progress without approval.
2818
3673
  // Detect this early and guide the developer instead of failing silently after branch creation.
3674
+ const pendingApv2 = (task.approvals || []).find(a => a.state === 'pending') || null
2819
3675
  const hasApprovedApv2 = (task.approvals || []).some(a => a.state === 'approved')
3676
+ const approvalState = pendingApv2 ? 'pending' : hasApprovedApv2 ? 'approved' : 'none'
2820
3677
  const PLANNING_COLS = ['backlog', 'todo']
2821
3678
  const needsApproval = PLANNING_COLS.includes(task.column) && !hasApprovedApv2
2822
3679
  if (needsApproval && !confirmed) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "internaltool-mcp",
3
- "version": "1.6.22",
3
+ "version": "1.6.25",
4
4
  "description": "MCP server for InternalTool — connect AI assistants (Claude Code, Cursor) to your project and task management platform",
5
5
  "type": "module",
6
6
  "main": "index.js",