@ezmodo/mcp-server 0.13.0

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 (98) hide show
  1. package/README.md +305 -0
  2. package/config/development.js +20 -0
  3. package/config/endpoint-map.js +351 -0
  4. package/config/index.js +34 -0
  5. package/config/production.js +18 -0
  6. package/config/staging.js +18 -0
  7. package/handlers/access.js +141 -0
  8. package/handlers/activity.js +112 -0
  9. package/handlers/agents.js +95 -0
  10. package/handlers/ai-intelligence.js +55 -0
  11. package/handlers/attachments.js +30 -0
  12. package/handlers/catalogs.js +169 -0
  13. package/handlers/components.js +282 -0
  14. package/handlers/context-manifest.js +1150 -0
  15. package/handlers/decisions.js +114 -0
  16. package/handlers/designs.js +118 -0
  17. package/handlers/documents.js +227 -0
  18. package/handlers/entities.js +95 -0
  19. package/handlers/epics.js +190 -0
  20. package/handlers/facts.js +62 -0
  21. package/handlers/feature-flags.js +142 -0
  22. package/handlers/features.js +137 -0
  23. package/handlers/folders.js +127 -0
  24. package/handlers/git-context.js +917 -0
  25. package/handlers/github.js +72 -0
  26. package/handlers/graph.js +23 -0
  27. package/handlers/index.js +205 -0
  28. package/handlers/links.js +156 -0
  29. package/handlers/milestones.js +131 -0
  30. package/handlers/organizations.js +14 -0
  31. package/handlers/projects.js +122 -0
  32. package/handlers/recurring-tasks.js +33 -0
  33. package/handlers/tags.js +124 -0
  34. package/handlers/tasks.js +561 -0
  35. package/handlers/testing.js +116 -0
  36. package/handlers/todos.js +43 -0
  37. package/handlers/watchers.js +54 -0
  38. package/handlers/work-templates.js +32 -0
  39. package/index.js +175 -0
  40. package/lib/active-session.js +86 -0
  41. package/lib/auto-assign.js +93 -0
  42. package/lib/autolink.js +176 -0
  43. package/lib/changed-files.js +22 -0
  44. package/lib/env.js +45 -0
  45. package/lib/git-helpers.js +553 -0
  46. package/lib/git-utils.js +73 -0
  47. package/lib/http-client.js +164 -0
  48. package/lib/links-at-create.js +94 -0
  49. package/lib/local-cache.js +140 -0
  50. package/lib/logger.js +109 -0
  51. package/lib/manifest-loader.js +182 -0
  52. package/lib/manifest-query.js +686 -0
  53. package/lib/repo-config-dir.js +118 -0
  54. package/lib/version.js +10 -0
  55. package/lib/web-url.js +69 -0
  56. package/lib/worktree-tools.js +950 -0
  57. package/package.json +62 -0
  58. package/prompts/ai-workflow-automation.js +96 -0
  59. package/prompts/index.js +39 -0
  60. package/prompts/zephly-usage-guide-content.txt +631 -0
  61. package/prompts/zephly-usage-guide.js +119 -0
  62. package/tools/access-entity-types.js +28 -0
  63. package/tools/access.js +152 -0
  64. package/tools/activity.js +38 -0
  65. package/tools/agents.js +208 -0
  66. package/tools/ai-intelligence.js +111 -0
  67. package/tools/attachments.js +92 -0
  68. package/tools/catalogs.js +341 -0
  69. package/tools/components.js +249 -0
  70. package/tools/context-manifest.js +236 -0
  71. package/tools/decisions.js +168 -0
  72. package/tools/designs.js +222 -0
  73. package/tools/documents.js +287 -0
  74. package/tools/entities.js +223 -0
  75. package/tools/epics.js +267 -0
  76. package/tools/facts.js +70 -0
  77. package/tools/feature-flags.js +300 -0
  78. package/tools/features.js +246 -0
  79. package/tools/folders.js +122 -0
  80. package/tools/git-context.js +109 -0
  81. package/tools/github.js +172 -0
  82. package/tools/graph.js +70 -0
  83. package/tools/index.js +77 -0
  84. package/tools/link-params.js +93 -0
  85. package/tools/linkable-types.js +36 -0
  86. package/tools/links.js +199 -0
  87. package/tools/milestones.js +176 -0
  88. package/tools/organizations.js +23 -0
  89. package/tools/projects.js +172 -0
  90. package/tools/recurring-tasks.js +115 -0
  91. package/tools/tags.js +219 -0
  92. package/tools/task-item-schema.js +57 -0
  93. package/tools/task-type.js +33 -0
  94. package/tools/tasks.js +680 -0
  95. package/tools/testing.js +344 -0
  96. package/tools/todos.js +69 -0
  97. package/tools/watchers.js +81 -0
  98. package/tools/work-templates.js +96 -0
@@ -0,0 +1,950 @@
1
+ /**
2
+ * Git Worktree Tools for MCP Server
3
+ * Implements worktree management operations for AI agents
4
+ */
5
+
6
+ import path from 'path';
7
+ import { callZephlyAPI } from './http-client.js';
8
+ import { getLogger } from './logger.js';
9
+ import {
10
+ isGitRepository,
11
+ getRepositoryRoot,
12
+ branchExists,
13
+ createBranch,
14
+ createWorktree,
15
+ listWorktrees,
16
+ removeWorktree,
17
+ getWorktreeStatus,
18
+ getAheadBehindCounts,
19
+ checkForConflicts,
20
+ deleteBranch,
21
+ isBranchMerged,
22
+ getLastCommit,
23
+ generateBranchName,
24
+ generateEpicBranchName,
25
+ getCurrentBranch,
26
+ getCommitsSince,
27
+ getCommitUrlBase,
28
+ getMergeBase,
29
+ getDefaultBranch,
30
+ } from './git-helpers.js';
31
+
32
+ /**
33
+ * Tool Definitions for MCP Server
34
+ */
35
+ export const WORKTREE_TOOLS = [
36
+ {
37
+ name: 'manage_worktree',
38
+ description: 'Create, sync, or clean up git worktrees for tasks and epics. ' +
39
+ 'Worktrees enable parallel development by creating isolated workspaces.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ action: {
44
+ type: 'string',
45
+ enum: ['create_epic', 'create_task', 'sync_status', 'link_branch_commits', 'cleanup'],
46
+ description: 'Action to perform',
47
+ },
48
+ // --- Identifiers ---
49
+ epicId: {
50
+ type: 'string',
51
+ description: 'Epic ID (required for create_epic)',
52
+ },
53
+ taskId: {
54
+ type: 'string',
55
+ description: 'Task ID (required for create_task, sync_status, link_branch_commits)',
56
+ },
57
+ projectId: {
58
+ type: 'string',
59
+ description: 'Project ID (required for cleanup)',
60
+ },
61
+ // --- Shared creation fields ---
62
+ repositoryPath: {
63
+ type: 'string',
64
+ description: 'Path to the git repository root (create_epic, create_task)',
65
+ },
66
+ worktreePath: {
67
+ type: 'string',
68
+ description: 'Path where the worktree should be created (create_epic, create_task) or synced from (sync_status)',
69
+ },
70
+ baseBranch: {
71
+ type: 'string',
72
+ description: 'Base branch to branch from (create_epic, create_task, link_branch_commits)',
73
+ },
74
+ branchName: {
75
+ type: 'string',
76
+ description: 'Custom branch name. Auto-generated if not provided (create_epic, create_task)',
77
+ },
78
+ // --- create_epic specific ---
79
+ updateTasks: {
80
+ type: 'boolean',
81
+ description: 'Update all tasks in the epic to reference the epic branch (create_epic only). Default: true',
82
+ default: true,
83
+ },
84
+ // --- sync_status specific ---
85
+ autoLinkCommits: {
86
+ type: 'boolean',
87
+ description: 'Automatically detect and link new commits (sync_status only). Default: true',
88
+ default: true,
89
+ },
90
+ // --- link_branch_commits specific ---
91
+ repoPath: {
92
+ type: 'string',
93
+ description: 'Path to the git repository (link_branch_commits only). Defaults to cwd.',
94
+ },
95
+ branch: {
96
+ type: 'string',
97
+ description: 'Branch to get commits from (link_branch_commits only). Defaults to current.',
98
+ },
99
+ sinceCommit: {
100
+ type: 'string',
101
+ description: 'Only link commits after this SHA (link_branch_commits only)',
102
+ },
103
+ maxCommits: {
104
+ type: 'number',
105
+ description: 'Max commits to link (link_branch_commits only). Default: 100',
106
+ default: 100,
107
+ },
108
+ // --- cleanup specific ---
109
+ taskIds: {
110
+ type: 'array',
111
+ items: { type: 'string' },
112
+ description: 'Specific task IDs to clean up (cleanup only). Omit for all completed/cancelled.',
113
+ },
114
+ dryRun: {
115
+ type: 'boolean',
116
+ description: 'Preview cleanup without making changes (cleanup only)',
117
+ default: false,
118
+ },
119
+ deleteBranches: {
120
+ type: 'boolean',
121
+ description: 'Also delete merged branches (cleanup only)',
122
+ default: false,
123
+ },
124
+ },
125
+ required: ['action'],
126
+ },
127
+ },
128
+ {
129
+ name: 'list_project_worktrees',
130
+ description: 'List all worktrees for a project with their associated tasks and status.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ projectId: {
135
+ type: 'string',
136
+ description: 'Project ID to list worktrees for',
137
+ },
138
+ repositoryPath: {
139
+ type: 'string',
140
+ description: 'Path to the git repository. If not provided, attempts to detect from project metadata.',
141
+ },
142
+ },
143
+ required: ['projectId'],
144
+ },
145
+ },
146
+ ];
147
+
148
+ /**
149
+ * Dispatch manage_worktree actions to the appropriate handler
150
+ */
151
+ export async function manageWorktree(args) {
152
+ const { action, ...params } = args;
153
+ switch (action) {
154
+ case 'create_epic': return createEpicWorktree(params);
155
+ case 'create_task': return createTaskWorktree(params);
156
+ case 'sync_status': return syncTaskWorktreeStatus(params);
157
+ case 'link_branch_commits': return linkBranchCommitsToTask(params);
158
+ case 'cleanup': return cleanupTaskWorktrees(params);
159
+ default: throw new Error(`Unknown action: ${action}`);
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create a worktree for an epic
165
+ * @param {object} args - Arguments from MCP tool call
166
+ * @returns {object} Result with worktree info
167
+ */
168
+ async function createEpicWorktree(args) {
169
+ const { epicId, repositoryPath, worktreePath, baseBranch, branchName, updateTasks = true } = args;
170
+
171
+ try {
172
+ // Get epic details
173
+ // Note: callZephlyAPI unwraps Go API responses, so we get {epic} directly
174
+ const epicData = await callZephlyAPI('mcpGetEpic', { epicId });
175
+ if (!epicData || !epicData.epic) {
176
+ throw new Error('Epic not found');
177
+ }
178
+
179
+ const epic = epicData.epic;
180
+
181
+ // Get project context to determine repository path and base branch
182
+ // Project-First: epics belong to projects (required)
183
+ let projectResult = null;
184
+ if (epic.projectId) {
185
+ try {
186
+ projectResult = await callZephlyAPI('mcpGetProjectContext', {
187
+ projectId: epic.projectId,
188
+ });
189
+ } catch (err) {
190
+ // Project context is optional, continue with defaults
191
+ getLogger().warn('Could not get project context for worktree', { error: err.message });
192
+ }
193
+ }
194
+
195
+ // Determine repository path
196
+ let repoPath = repositoryPath;
197
+ if (!repoPath && projectResult?.project?.gitContext?.repositoryPath) {
198
+ repoPath = projectResult.project.gitContext.repositoryPath;
199
+ }
200
+ if (!repoPath) {
201
+ repoPath = process.cwd();
202
+ }
203
+
204
+ // Verify it's a git repository
205
+ if (!isGitRepository(repoPath)) {
206
+ throw new Error(`Not a git repository: ${repoPath}`);
207
+ }
208
+
209
+ // Get repository root
210
+ const repoRoot = getRepositoryRoot(repoPath);
211
+ if (!repoRoot) {
212
+ throw new Error('Could not determine repository root');
213
+ }
214
+
215
+ // Determine base branch
216
+ let baseRef = baseBranch;
217
+ if (!baseRef && projectResult?.project?.gitContext?.defaultBranch) {
218
+ baseRef = projectResult.project.gitContext.defaultBranch;
219
+ }
220
+ if (!baseRef) {
221
+ baseRef = getCurrentBranch(repoRoot) || 'main';
222
+ }
223
+
224
+ // Generate or use provided branch name
225
+ const newBranchName = branchName || generateEpicBranchName(epic);
226
+
227
+ // Check if branch already exists
228
+ const exists = branchExists(repoRoot, newBranchName);
229
+ if (exists.local) {
230
+ throw new Error(`Branch ${newBranchName} already exists locally`);
231
+ }
232
+
233
+ // Determine worktree path
234
+ let wtPath = worktreePath;
235
+ if (!wtPath && projectResult?.project?.gitContext?.defaultWorktreePath) {
236
+ wtPath = path.join(repoRoot, projectResult.project.gitContext.defaultWorktreePath, `epic-${epicId}`);
237
+ }
238
+ if (!wtPath) {
239
+ wtPath = path.join(path.dirname(repoRoot), 'worktrees', `epic-${epicId}`);
240
+ }
241
+
242
+ // Create branch
243
+ const baseCommit = createBranch(repoRoot, newBranchName, baseRef);
244
+
245
+ // Create worktree
246
+ createWorktree(repoRoot, wtPath, newBranchName, false);
247
+
248
+ // Update epic with git context
249
+ const epicGitContext = {
250
+ branchName: newBranchName,
251
+ baseBranch: baseRef,
252
+ baseCommit,
253
+ lastSyncedAt: new Date(),
254
+ isDirty: false,
255
+ aheadBy: 0,
256
+ behindBy: 0,
257
+ };
258
+
259
+ await callZephlyAPI('mcpUpdateEpic', {
260
+ epicId,
261
+ gitContext: epicGitContext,
262
+ });
263
+
264
+ // Optionally update all tasks in the epic to reference the epic branch
265
+ let updatedTaskCount = 0;
266
+ if (updateTasks) {
267
+ // Get all tasks in the epic
268
+ // Project-First: tasks belong to projects with optional epic grouping
269
+ const searchParams = { epicId };
270
+ if (epic.projectId) {
271
+ searchParams.projectId = epic.projectId;
272
+ }
273
+ const tasksData = await callZephlyAPI('mcpSearchTasks', searchParams);
274
+
275
+ if (tasksData?.tasks) {
276
+ for (const task of tasksData.tasks) {
277
+ await callZephlyAPI('mcpUpdateTask', {
278
+ taskId: task.id,
279
+ gitContext: {
280
+ branchName: newBranchName,
281
+ baseBranch: baseRef,
282
+ baseCommit,
283
+ lastSyncedAt: new Date(),
284
+ isDirty: false,
285
+ aheadBy: 0,
286
+ behindBy: 0,
287
+ isEpicBranch: true, // Flag to indicate this is shared via epic
288
+ epicId, // Reference back to epic
289
+ },
290
+ });
291
+ updatedTaskCount++;
292
+ }
293
+ }
294
+ }
295
+
296
+ return {
297
+ success: true,
298
+ worktreePath: wtPath,
299
+ branchName: newBranchName,
300
+ baseBranch: baseRef,
301
+ baseCommit,
302
+ updatedTaskCount,
303
+ message: `Created epic worktree at ${wtPath} on branch ` +
304
+ `${newBranchName}` +
305
+ `${updatedTaskCount > 0
306
+ ? ` (updated ${updatedTaskCount} tasks)` : ''}`,
307
+ };
308
+ } catch (error) {
309
+ return {
310
+ success: false,
311
+ error: error.message,
312
+ };
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Create a worktree for a task
318
+ * @param {object} args - Arguments from MCP tool call
319
+ * @returns {object} Result with worktree info
320
+ */
321
+ async function createTaskWorktree(args) {
322
+ const { taskId, repositoryPath, worktreePath, baseBranch, branchName } = args;
323
+
324
+ try {
325
+ // Get task details
326
+ // Note: callZephlyAPI unwraps Go API responses, so we get {task} directly
327
+ const taskData = await callZephlyAPI('mcpGetTask', { taskId });
328
+ if (!taskData || !taskData.task) {
329
+ throw new Error('Task not found');
330
+ }
331
+
332
+ const task = taskData.task;
333
+
334
+ // Get project context to determine repository path and base branch
335
+ const projectResult = await callZephlyAPI('mcpGetProjectContext', {
336
+ projectId: task.projectId,
337
+ });
338
+
339
+ // Determine repository path
340
+ let repoPath = repositoryPath;
341
+ if (!repoPath && projectResult?.project?.gitContext?.repositoryPath) {
342
+ repoPath = projectResult.project.gitContext.repositoryPath;
343
+ }
344
+ if (!repoPath) {
345
+ repoPath = process.cwd();
346
+ }
347
+
348
+ // Verify it's a git repository
349
+ if (!isGitRepository(repoPath)) {
350
+ throw new Error(`Not a git repository: ${repoPath}`);
351
+ }
352
+
353
+ // Get repository root
354
+ const repoRoot = getRepositoryRoot(repoPath);
355
+ if (!repoRoot) {
356
+ throw new Error('Could not determine repository root');
357
+ }
358
+
359
+ // Determine base branch
360
+ let baseRef = baseBranch;
361
+ if (!baseRef && projectResult?.project?.gitContext?.defaultBranch) {
362
+ baseRef = projectResult.project.gitContext.defaultBranch;
363
+ }
364
+ if (!baseRef) {
365
+ baseRef = getCurrentBranch(repoRoot) || 'main';
366
+ }
367
+
368
+ // Generate or use provided branch name
369
+ const newBranchName = branchName || generateBranchName(task);
370
+
371
+ // Check if branch already exists
372
+ const exists = branchExists(repoRoot, newBranchName);
373
+ if (exists.local) {
374
+ throw new Error(`Branch ${newBranchName} already exists locally`);
375
+ }
376
+
377
+ // Determine worktree path
378
+ let wtPath = worktreePath;
379
+ if (!wtPath && projectResult?.project?.gitContext?.defaultWorktreePath) {
380
+ wtPath = path.join(repoRoot, projectResult.project.gitContext.defaultWorktreePath, taskId);
381
+ }
382
+ if (!wtPath) {
383
+ wtPath = path.join(path.dirname(repoRoot), 'worktrees', taskId);
384
+ }
385
+
386
+ // Create branch
387
+ const baseCommit = createBranch(repoRoot, newBranchName, baseRef);
388
+
389
+ // Create worktree
390
+ createWorktree(repoRoot, wtPath, newBranchName, false);
391
+
392
+ // Update task with git context
393
+ const gitContext = {
394
+ branchName: newBranchName,
395
+ baseBranch: baseRef,
396
+ baseCommit,
397
+ lastSyncedAt: new Date(),
398
+ isDirty: false,
399
+ aheadBy: 0,
400
+ behindBy: 0,
401
+ };
402
+
403
+ await callZephlyAPI('mcpUpdateTask', {
404
+ taskId,
405
+ gitContext,
406
+ });
407
+
408
+ return {
409
+ success: true,
410
+ worktreePath: wtPath,
411
+ branchName: newBranchName,
412
+ baseBranch: baseRef,
413
+ baseCommit,
414
+ message: `Created worktree at ${wtPath} on branch ${newBranchName}`,
415
+ };
416
+ } catch (error) {
417
+ return {
418
+ success: false,
419
+ error: error.message,
420
+ };
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Sync worktree status to task
426
+ * @param {object} args - Arguments from MCP tool call
427
+ * @param {string} args.taskId - Task ID
428
+ * @param {string} [args.worktreePath] - Path to worktree (optional if stored in task)
429
+ * @param {boolean} [args.autoLinkCommits=true] - Automatically link new commits
430
+ * @returns {object} Result with status info
431
+ */
432
+ async function syncTaskWorktreeStatus(args) {
433
+ const { taskId, worktreePath, autoLinkCommits = true } = args;
434
+
435
+ try {
436
+ // Get task details
437
+ // Note: callZephlyAPI unwraps Go API responses, so we get {task} directly
438
+ const taskData = await callZephlyAPI('mcpGetTask', { taskId });
439
+ if (!taskData || !taskData.task) {
440
+ throw new Error('Task not found');
441
+ }
442
+
443
+ const task = taskData.task;
444
+
445
+ if (!task.gitContext || !task.gitContext.branchName) {
446
+ throw new Error('Task has no git context');
447
+ }
448
+
449
+ // Determine worktree path
450
+ let wtPath = worktreePath;
451
+ if (!wtPath && task.gitContext.worktreePath) {
452
+ wtPath = task.gitContext.worktreePath;
453
+ }
454
+ if (!wtPath) {
455
+ throw new Error('Worktree path not specified and not found in task');
456
+ }
457
+
458
+ // Verify worktree exists
459
+ if (!isGitRepository(wtPath)) {
460
+ throw new Error(`Worktree not found or not a git repository: ${wtPath}`);
461
+ }
462
+
463
+ // Get status
464
+ const status = getWorktreeStatus(wtPath);
465
+
466
+ // Get ahead/behind counts
467
+ const { ahead, behind } = getAheadBehindCounts(
468
+ wtPath,
469
+ task.gitContext.branchName,
470
+ `origin/${task.gitContext.baseBranch}`
471
+ );
472
+
473
+ // Check for conflicts
474
+ const conflicts = checkForConflicts(wtPath);
475
+
476
+ // Get last commit info
477
+ const lastCommit = getLastCommit(wtPath, task.gitContext.branchName);
478
+
479
+ // Auto-link new commits if enabled
480
+ let newlyLinkedCommits = [];
481
+ if (autoLinkCommits) {
482
+ // Determine the starting point for detecting new commits
483
+ // Use lastLinkedCommitSha if available, otherwise fall back to baseCommit
484
+ const sinceCommit = task.gitContext.lastLinkedCommitSha || task.gitContext.baseCommit;
485
+
486
+ // Get all commits since the last linked commit
487
+ const commits = getCommitsSince(wtPath, sinceCommit, task.gitContext.branchName);
488
+
489
+ // Get existing linked commit SHAs to avoid duplicates
490
+ const existingCommitShas = new Set(
491
+ (task.linkedCommits || []).map(c => c.sha)
492
+ );
493
+
494
+ // Filter to only new commits
495
+ const newCommits = commits.filter(c => !existingCommitShas.has(c.sha));
496
+
497
+ // Get commit URL base for generating links
498
+ const commitUrlBase = getCommitUrlBase(wtPath);
499
+
500
+ // Link each new commit
501
+ for (const commit of newCommits) {
502
+ try {
503
+ const commitUrl = commitUrlBase ? `${commitUrlBase}/commit/${commit.sha}` : undefined;
504
+
505
+ await callZephlyAPI('mcpLinkCommitToTask', {
506
+ taskId,
507
+ sha: commit.sha,
508
+ message: commit.message,
509
+ author: commit.author,
510
+ email: commit.email,
511
+ timestamp: commit.timestamp.toISOString(),
512
+ url: commitUrl,
513
+ branch: task.gitContext.branchName,
514
+ });
515
+
516
+ newlyLinkedCommits.push(commit);
517
+ } catch (linkError) {
518
+ // Log but don't fail the sync if a single commit link fails
519
+ getLogger().warn('Failed to link commit', { sha: commit.shortSha, error: linkError.message });
520
+ }
521
+ }
522
+ }
523
+
524
+ // Update task git context
525
+ const updatedGitContext = {
526
+ ...task.gitContext,
527
+ isDirty: status.isDirty,
528
+ aheadBy: ahead,
529
+ behindBy: behind,
530
+ hasConflicts: conflicts.hasConflicts,
531
+ conflictFiles: conflicts.files,
532
+ lastSyncedAt: new Date(),
533
+ };
534
+
535
+ if (lastCommit) {
536
+ updatedGitContext.lastPushedBy = lastCommit.author;
537
+ updatedGitContext.lastPushedAt = lastCommit.date;
538
+ // Track the last commit we've linked to avoid re-linking on next sync
539
+ if (autoLinkCommits && newlyLinkedCommits.length > 0) {
540
+ updatedGitContext.lastLinkedCommitSha = lastCommit.hash;
541
+ }
542
+ }
543
+
544
+ await callZephlyAPI('mcpUpdateTask', {
545
+ taskId,
546
+ gitContext: updatedGitContext,
547
+ });
548
+
549
+ return {
550
+ success: true,
551
+ status: {
552
+ isDirty: status.isDirty,
553
+ aheadBy: ahead,
554
+ behindBy: behind,
555
+ hasConflicts: conflicts.hasConflicts,
556
+ conflictFiles: conflicts.files,
557
+ files: status.files,
558
+ lastCommit,
559
+ },
560
+ linkedCommits: newlyLinkedCommits.length > 0 ? {
561
+ count: newlyLinkedCommits.length,
562
+ commits: newlyLinkedCommits.map(c => ({ sha: c.shortSha, message: c.message })),
563
+ } : undefined,
564
+ };
565
+ } catch (error) {
566
+ return {
567
+ success: false,
568
+ error: error.message,
569
+ };
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Link commits from a branch to a task (works without worktree)
575
+ * @param {object} args - Arguments from MCP tool call
576
+ * @param {string} args.taskId - Task ID to link commits to
577
+ * @param {string} [args.repoPath] - Path to git repository (defaults to cwd)
578
+ * @param {string} [args.branch] - Branch to get commits from (defaults to current branch)
579
+ * @param {string} [args.sinceCommit] - Only link commits after this SHA
580
+ * @param {string} [args.baseBranch] - Base branch to compare against (defaults to main/master)
581
+ * @param {number} [args.maxCommits=100] - Maximum number of commits to link
582
+ * @returns {object} Result with linked commits info
583
+ */
584
+ async function linkBranchCommitsToTask(args) {
585
+ const { taskId, repoPath, branch, sinceCommit, baseBranch, maxCommits = 100 } = args;
586
+
587
+ try {
588
+ // Determine repo path
589
+ const gitRepoPath = repoPath || process.cwd();
590
+
591
+ // Verify it's a git repository
592
+ if (!isGitRepository(gitRepoPath)) {
593
+ throw new Error(`Not a git repository: ${gitRepoPath}`);
594
+ }
595
+
596
+ // Get current branch if not specified
597
+ const targetBranch = branch || getCurrentBranch(gitRepoPath);
598
+ if (!targetBranch) {
599
+ throw new Error('Could not determine current branch');
600
+ }
601
+
602
+ // Get task to check for existing linked commits
603
+ const taskData = await callZephlyAPI('mcpGetTask', { taskId });
604
+ if (!taskData || !taskData.task) {
605
+ throw new Error('Task not found');
606
+ }
607
+ const task = taskData.task;
608
+
609
+ // Determine starting point for commits
610
+ let startCommit = sinceCommit;
611
+ if (!startCommit) {
612
+ // Check if task has gitContext with tracking info
613
+ if (task.gitContext?.lastLinkedCommitSha) {
614
+ startCommit = task.gitContext.lastLinkedCommitSha;
615
+ } else if (task.gitContext?.baseCommit) {
616
+ startCommit = task.gitContext.baseCommit;
617
+ } else {
618
+ // Find merge base with main/master
619
+ const defaultBase = baseBranch || getDefaultBranch(gitRepoPath);
620
+ startCommit = getMergeBase(gitRepoPath, targetBranch, defaultBase);
621
+ }
622
+ }
623
+
624
+ // Get commits since start point
625
+ let commits = getCommitsSince(gitRepoPath, startCommit, targetBranch);
626
+
627
+ // Limit commits
628
+ if (commits.length > maxCommits) {
629
+ commits = commits.slice(0, maxCommits);
630
+ }
631
+
632
+ // Get existing linked commit SHAs to avoid duplicates
633
+ const existingCommitShas = new Set(
634
+ (task.linkedCommits || []).map(c => c.sha)
635
+ );
636
+
637
+ // Filter to only new commits
638
+ const newCommits = commits.filter(c => !existingCommitShas.has(c.sha));
639
+
640
+ if (newCommits.length === 0) {
641
+ return {
642
+ success: true,
643
+ message: 'No new commits to link',
644
+ branch: targetBranch,
645
+ existingCommitCount: existingCommitShas.size,
646
+ };
647
+ }
648
+
649
+ // Get commit URL base for generating links
650
+ const commitUrlBase = getCommitUrlBase(gitRepoPath);
651
+
652
+ // Link each new commit
653
+ const linkedCommits = [];
654
+ const errors = [];
655
+
656
+ for (const commit of newCommits) {
657
+ try {
658
+ const commitUrl = commitUrlBase ? `${commitUrlBase}/commit/${commit.sha}` : undefined;
659
+
660
+ await callZephlyAPI('mcpLinkCommitToTask', {
661
+ taskId,
662
+ sha: commit.sha,
663
+ message: commit.message,
664
+ author: commit.author,
665
+ email: commit.email,
666
+ timestamp: commit.timestamp.toISOString(),
667
+ url: commitUrl,
668
+ branch: targetBranch,
669
+ });
670
+
671
+ linkedCommits.push(commit);
672
+ } catch (linkError) {
673
+ errors.push({ sha: commit.shortSha, error: linkError.message });
674
+ }
675
+ }
676
+
677
+ // Update task gitContext with tracking info if not already set
678
+ if (!task.gitContext || !task.gitContext.branchName) {
679
+ const lastCommit = getLastCommit(gitRepoPath, targetBranch);
680
+ const defaultBase = baseBranch || getDefaultBranch(gitRepoPath);
681
+
682
+ await callZephlyAPI('mcpUpdateTask', {
683
+ taskId,
684
+ gitContext: {
685
+ ...task.gitContext,
686
+ branchName: targetBranch,
687
+ baseBranch: defaultBase,
688
+ baseCommit: startCommit || getMergeBase(gitRepoPath, targetBranch, defaultBase),
689
+ lastLinkedCommitSha: lastCommit?.hash,
690
+ lastSyncedAt: new Date(),
691
+ },
692
+ });
693
+ } else if (linkedCommits.length > 0) {
694
+ // Just update the last linked commit SHA
695
+ const lastCommit = getLastCommit(gitRepoPath, targetBranch);
696
+ await callZephlyAPI('mcpUpdateTask', {
697
+ taskId,
698
+ gitContext: {
699
+ ...task.gitContext,
700
+ lastLinkedCommitSha: lastCommit?.hash,
701
+ lastSyncedAt: new Date(),
702
+ },
703
+ });
704
+ }
705
+
706
+ return {
707
+ success: true,
708
+ branch: targetBranch,
709
+ linkedCommits: {
710
+ count: linkedCommits.length,
711
+ commits: linkedCommits.map(c => ({ sha: c.shortSha, message: c.message })),
712
+ },
713
+ errors: errors.length > 0 ? errors : undefined,
714
+ skipped: commits.length - newCommits.length,
715
+ };
716
+ } catch (error) {
717
+ return {
718
+ success: false,
719
+ error: error.message,
720
+ };
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Clean up worktrees for completed/cancelled tasks
726
+ * @param {object} args - Arguments from MCP tool call
727
+ * @returns {object} Result with cleanup info
728
+ */
729
+ async function cleanupTaskWorktrees(args) {
730
+ const { projectId, taskIds, dryRun = false, deleteBranches = false } = args;
731
+
732
+ try {
733
+ // Get project context
734
+ const projectData = await callZephlyAPI('mcpGetProjectContext', { projectId });
735
+ if (!projectData?.project) {
736
+ throw new Error('Failed to get project context');
737
+ }
738
+
739
+ // Determine repository path
740
+ const repoPath = projectData.project?.gitContext?.repositoryPath || process.cwd();
741
+
742
+ if (!isGitRepository(repoPath)) {
743
+ throw new Error(`Not a git repository: ${repoPath}`);
744
+ }
745
+
746
+ const repoRoot = getRepositoryRoot(repoPath);
747
+
748
+ // Get tasks to clean up
749
+ let tasksToCleanup = [];
750
+ if (taskIds && taskIds.length > 0) {
751
+ // Specific tasks
752
+ for (const taskId of taskIds) {
753
+ const taskData = await callZephlyAPI('mcpGetTask', { taskId });
754
+ if (taskData?.task) {
755
+ tasksToCleanup.push(taskData.task);
756
+ }
757
+ }
758
+ } else {
759
+ // Find all completed/cancelled tasks with worktrees
760
+ const completedData = await callZephlyAPI('mcpSearchTasks', {
761
+ projectId,
762
+ status: 'completed',
763
+ });
764
+
765
+ const cancelledData = await callZephlyAPI('mcpSearchTasks', {
766
+ projectId,
767
+ status: 'cancelled',
768
+ });
769
+
770
+ if (completedData?.tasks) {
771
+ tasksToCleanup.push(...completedData.tasks);
772
+ }
773
+ if (cancelledData?.tasks) {
774
+ tasksToCleanup.push(...cancelledData.tasks);
775
+ }
776
+
777
+ // Filter to only tasks with git context
778
+ tasksToCleanup = tasksToCleanup.filter(t => t.gitContext && t.gitContext.branchName);
779
+ }
780
+
781
+ // Clean up each worktree
782
+ const results = [];
783
+ const existingWorktrees = listWorktrees(repoRoot);
784
+
785
+ for (const task of tasksToCleanup) {
786
+ const result = {
787
+ taskId: task.id,
788
+ taskTitle: task.title,
789
+ branchName: task.gitContext.branchName,
790
+ action: 'skipped',
791
+ reason: '',
792
+ };
793
+
794
+ // Find matching worktree
795
+ const worktree = existingWorktrees.find(
796
+ wt => wt.branch === task.gitContext.branchName
797
+ );
798
+
799
+ if (!worktree) {
800
+ result.reason = 'Worktree not found';
801
+ results.push(result);
802
+ continue;
803
+ }
804
+
805
+ if (dryRun) {
806
+ result.action = 'would-remove';
807
+ result.worktreePath = worktree.path;
808
+
809
+ if (deleteBranches) {
810
+ const merged = isBranchMerged(
811
+ repoRoot,
812
+ task.gitContext.branchName,
813
+ task.gitContext.baseBranch
814
+ );
815
+ if (merged) {
816
+ result.branchAction = 'would-delete';
817
+ result.reason = 'Branch is merged';
818
+ } else {
819
+ result.branchAction = 'would-keep';
820
+ result.reason = 'Branch not merged';
821
+ }
822
+ }
823
+ } else {
824
+ try {
825
+ // Remove worktree
826
+ removeWorktree(repoRoot, worktree.path, true);
827
+ result.action = 'removed';
828
+ result.worktreePath = worktree.path;
829
+
830
+ // Delete branch if requested and merged
831
+ if (deleteBranches) {
832
+ const merged = isBranchMerged(
833
+ repoRoot,
834
+ task.gitContext.branchName,
835
+ task.gitContext.baseBranch
836
+ );
837
+
838
+ if (merged) {
839
+ deleteBranch(repoRoot, task.gitContext.branchName, false);
840
+ result.branchAction = 'deleted';
841
+ result.reason = 'Branch was merged';
842
+ } else {
843
+ result.branchAction = 'kept';
844
+ result.reason = 'Branch not merged (kept for safety)';
845
+ }
846
+ }
847
+
848
+ // Clear git context from task
849
+ await callZephlyAPI('mcpUpdateTask', {
850
+ taskId: task.id,
851
+ gitContext: null,
852
+ });
853
+ } catch (error) {
854
+ result.action = 'failed';
855
+ result.reason = error.message;
856
+ }
857
+ }
858
+
859
+ results.push(result);
860
+ }
861
+
862
+ return {
863
+ success: true,
864
+ dryRun,
865
+ cleaned: results.filter(r => r.action === 'removed').length,
866
+ wouldClean: results.filter(r => r.action === 'would-remove').length,
867
+ results,
868
+ };
869
+ } catch (error) {
870
+ return {
871
+ success: false,
872
+ error: error.message,
873
+ };
874
+ }
875
+ }
876
+
877
+ /**
878
+ * List all worktrees for a project
879
+ * @param {object} args - Arguments from MCP tool call
880
+ * @returns {object} Result with worktree list
881
+ */
882
+ export async function listProjectWorktrees(args) {
883
+ const { projectId, repositoryPath } = args;
884
+
885
+ try {
886
+ // Get project context
887
+ const projectData = await callZephlyAPI('mcpGetProjectContext', { projectId });
888
+ if (!projectData?.project) {
889
+ throw new Error('Failed to get project context');
890
+ }
891
+
892
+ // Determine repository path
893
+ const repoPath = repositoryPath || projectData.project?.gitContext?.repositoryPath || process.cwd();
894
+
895
+ if (!isGitRepository(repoPath)) {
896
+ throw new Error(`Not a git repository: ${repoPath}`);
897
+ }
898
+
899
+ const repoRoot = getRepositoryRoot(repoPath);
900
+
901
+ // List all worktrees
902
+ const worktrees = listWorktrees(repoRoot);
903
+
904
+ // Get all tasks with git context
905
+ const tasksData = await callZephlyAPI('mcpSearchTasks', { projectId });
906
+ const tasks = tasksData?.tasks || [];
907
+ const tasksWithGit = tasks.filter(t => t.gitContext && t.gitContext.branchName);
908
+
909
+ // Match worktrees with tasks
910
+ const worktreeList = worktrees.map(wt => {
911
+ const task = tasksWithGit.find(t => t.gitContext.branchName === wt.branch);
912
+
913
+ const info = {
914
+ path: wt.path,
915
+ branch: wt.branch,
916
+ commit: wt.commit,
917
+ taskId: task?.id || null,
918
+ taskTitle: task?.title || null,
919
+ taskStatus: task?.status || null,
920
+ assignee: task?.assignee || null,
921
+ };
922
+
923
+ if (task && task.gitContext) {
924
+ info.isDirty = task.gitContext.isDirty;
925
+ info.aheadBy = task.gitContext.aheadBy;
926
+ info.behindBy = task.gitContext.behindBy;
927
+ info.hasConflicts = task.gitContext.hasConflicts;
928
+ info.lastSyncedAt = task.gitContext.lastSyncedAt;
929
+ }
930
+
931
+ return info;
932
+ });
933
+
934
+ // Find orphaned worktrees (no matching task)
935
+ const orphaned = worktreeList.filter(wt => !wt.taskId);
936
+
937
+ return {
938
+ success: true,
939
+ worktrees: worktreeList,
940
+ totalCount: worktreeList.length,
941
+ orphanedCount: orphaned.length,
942
+ orphaned,
943
+ };
944
+ } catch (error) {
945
+ return {
946
+ success: false,
947
+ error: error.message,
948
+ };
949
+ }
950
+ }