switchman-dev 0.1.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 (50) hide show
  1. package/CLAUDE.md +98 -0
  2. package/README.md +243 -0
  3. package/examples/README.md +117 -0
  4. package/examples/setup.sh +102 -0
  5. package/examples/taskapi/.switchman/switchman.db +0 -0
  6. package/examples/taskapi/package-lock.json +4736 -0
  7. package/examples/taskapi/package.json +18 -0
  8. package/examples/taskapi/src/db.js +179 -0
  9. package/examples/taskapi/src/middleware/auth.js +96 -0
  10. package/examples/taskapi/src/middleware/validate.js +133 -0
  11. package/examples/taskapi/src/routes/tasks.js +65 -0
  12. package/examples/taskapi/src/routes/users.js +38 -0
  13. package/examples/taskapi/src/server.js +7 -0
  14. package/examples/taskapi/tests/api.test.js +112 -0
  15. package/examples/teardown.sh +37 -0
  16. package/examples/walkthrough.sh +172 -0
  17. package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
  18. package/examples/worktrees/agent-rate-limiting/package.json +18 -0
  19. package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
  20. package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +96 -0
  21. package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +133 -0
  22. package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +65 -0
  23. package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
  24. package/examples/worktrees/agent-rate-limiting/src/server.js +7 -0
  25. package/examples/worktrees/agent-rate-limiting/tests/api.test.js +112 -0
  26. package/examples/worktrees/agent-tests/package-lock.json +4736 -0
  27. package/examples/worktrees/agent-tests/package.json +18 -0
  28. package/examples/worktrees/agent-tests/src/db.js +179 -0
  29. package/examples/worktrees/agent-tests/src/middleware/auth.js +96 -0
  30. package/examples/worktrees/agent-tests/src/middleware/validate.js +133 -0
  31. package/examples/worktrees/agent-tests/src/routes/tasks.js +65 -0
  32. package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
  33. package/examples/worktrees/agent-tests/src/server.js +7 -0
  34. package/examples/worktrees/agent-tests/tests/api.test.js +112 -0
  35. package/examples/worktrees/agent-validation/package-lock.json +4736 -0
  36. package/examples/worktrees/agent-validation/package.json +18 -0
  37. package/examples/worktrees/agent-validation/src/db.js +179 -0
  38. package/examples/worktrees/agent-validation/src/middleware/auth.js +96 -0
  39. package/examples/worktrees/agent-validation/src/middleware/validate.js +133 -0
  40. package/examples/worktrees/agent-validation/src/routes/tasks.js +65 -0
  41. package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
  42. package/examples/worktrees/agent-validation/src/server.js +7 -0
  43. package/examples/worktrees/agent-validation/tests/api.test.js +112 -0
  44. package/package.json +29 -0
  45. package/src/cli/index.js +602 -0
  46. package/src/core/db.js +240 -0
  47. package/src/core/detector.js +172 -0
  48. package/src/core/git.js +265 -0
  49. package/src/mcp/server.js +555 -0
  50. package/tests/test.js +259 -0
@@ -0,0 +1,555 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * switchman-mcp-server
4
+ *
5
+ * MCP server exposing Switchman's coordination primitives to AI coding agents.
6
+ * Transport: stdio (local subprocess, one session per agent process).
7
+ *
8
+ * Tools:
9
+ * switchman_task_next — get the next pending task (agents poll this)
10
+ * switchman_task_add — add a new task to the queue
11
+ * switchman_task_claim — claim files for a task (conflict-safe)
12
+ * switchman_task_done — mark a task complete + release file claims
13
+ * switchman_task_fail — mark a task failed + release file claims
14
+ * switchman_scan — scan all worktrees for conflicts right now
15
+ * switchman_status — full system overview (tasks, claims, worktrees)
16
+ */
17
+
18
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { z } from 'zod';
21
+
22
+ import { findRepoRoot } from '../core/git.js';
23
+ import {
24
+ openDb,
25
+ createTask,
26
+ assignTask,
27
+ completeTask,
28
+ failTask,
29
+ listTasks,
30
+ getTask,
31
+ getNextPendingTask,
32
+ claimFiles,
33
+ releaseFileClaims,
34
+ getActiveFileClaims,
35
+ checkFileConflicts,
36
+ listWorktrees,
37
+ } from '../core/db.js';
38
+ import { scanAllWorktrees } from '../core/detector.js';
39
+
40
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Get the repo root and open the database.
44
+ * Throws a structured error if not inside a git repo or not initialised.
45
+ */
46
+ function getContext() {
47
+ const repoRoot = findRepoRoot();
48
+ const db = openDb(repoRoot);
49
+ return { repoRoot, db };
50
+ }
51
+
52
+ /** Return a tool error response with a clear, actionable message. */
53
+ function toolError(message) {
54
+ return {
55
+ isError: true,
56
+ content: [{ type: 'text', text: `Error: ${message}` }],
57
+ };
58
+ }
59
+
60
+ /** Return a successful tool response. */
61
+ function toolOk(text, structured = undefined) {
62
+ const response = {
63
+ content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }],
64
+ };
65
+ if (structured !== undefined) response.structuredContent = structured;
66
+ return response;
67
+ }
68
+
69
+ // ─── Server ───────────────────────────────────────────────────────────────────
70
+
71
+ const server = new McpServer({
72
+ name: 'switchman-mcp-server',
73
+ version: '0.1.0',
74
+ });
75
+
76
+ // ── switchman_task_next ────────────────────────────────────────────────────────
77
+
78
+ server.registerTool(
79
+ 'switchman_task_next',
80
+ {
81
+ title: 'Get Next Pending Task',
82
+ description: `Returns the highest-priority pending task from the Switchman queue, then assigns it to the specified worktree so no other agent picks it up.
83
+
84
+ Call this at the start of each agent session to claim your work. Returns null if the queue is empty.
85
+
86
+ Args:
87
+ - worktree (string): The git worktree name this agent is running in (e.g. "feature-auth"). Run 'git worktree list' to find yours.
88
+ - agent (string, optional): Human-readable agent identifier for logging (e.g. "claude-code", "cursor").
89
+
90
+ Returns JSON:
91
+ {
92
+ "task": {
93
+ "id": string, // Task ID to use in subsequent calls
94
+ "title": string,
95
+ "description": string | null,
96
+ "priority": number, // 1-10, higher = more urgent
97
+ "worktree": string,
98
+ "status": "in_progress"
99
+ } | null // null when queue is empty
100
+ }
101
+
102
+ Examples:
103
+ - Agent starts up: call switchman_task_next with your worktree name
104
+ - Queue is empty: returns { "task": null } — agent should wait or stop
105
+ - After receiving a task: call switchman_task_claim to declare which files you'll edit`,
106
+ inputSchema: z.object({
107
+ worktree: z.string().min(1).describe("Your git worktree name. Run 'git worktree list' to find it."),
108
+ agent: z.string().optional().describe('Agent identifier for logging (e.g. "claude-code")'),
109
+ }),
110
+ annotations: {
111
+ readOnlyHint: false,
112
+ destructiveHint: false,
113
+ idempotentHint: false,
114
+ openWorldHint: false,
115
+ },
116
+ },
117
+ async ({ worktree, agent }) => {
118
+ try {
119
+ const { db } = getContext();
120
+ const task = getNextPendingTask(db);
121
+
122
+ if (!task) {
123
+ db.close();
124
+ return toolOk(JSON.stringify({ task: null }), { task: null });
125
+ }
126
+
127
+ const assigned = assignTask(db, task.id, worktree, agent ?? null);
128
+ db.close();
129
+
130
+ if (!assigned) {
131
+ // Race condition: another agent grabbed it first — try again
132
+ return toolOk(
133
+ JSON.stringify({ task: null, message: 'Task was claimed by another agent. Call switchman_task_next again.' }),
134
+ );
135
+ }
136
+
137
+ const result = { task: { ...task, worktree, status: 'in_progress' } };
138
+ return toolOk(JSON.stringify(result, null, 2), result);
139
+ } catch (err) {
140
+ return toolError(`${err.message}. Make sure switchman is initialised in this repo (run 'switchman init').`);
141
+ }
142
+ },
143
+ );
144
+
145
+ // ── switchman_task_add ─────────────────────────────────────────────────────────
146
+
147
+ server.registerTool(
148
+ 'switchman_task_add',
149
+ {
150
+ title: 'Add Task to Queue',
151
+ description: `Adds a new task to the Switchman task queue.
152
+
153
+ Use this to break down a large feature into parallelisable subtasks before spinning up worker agents.
154
+
155
+ Args:
156
+ - title (string): Short description of the work (e.g. "Implement OAuth login flow")
157
+ - description (string, optional): Detailed implementation notes
158
+ - priority (number, optional): 1-10, default 5. Higher = picked up first.
159
+ - id (string, optional): Custom task ID. Auto-generated if omitted.
160
+
161
+ Returns JSON:
162
+ {
163
+ "task_id": string, // ID to reference in assign/claim/done calls
164
+ "title": string,
165
+ "priority": number
166
+ }
167
+
168
+ Examples:
169
+ - Add high-priority security task: { title: "Fix SQL injection in login", priority: 9 }
170
+ - Add background task: { title: "Update README", priority: 2 }`,
171
+ inputSchema: z.object({
172
+ title: z.string().min(1).max(200).describe('Short description of the work'),
173
+ description: z.string().max(2000).optional().describe('Detailed implementation notes'),
174
+ priority: z.number().int().min(1).max(10).default(5).describe('Priority 1-10, higher = picked up first'),
175
+ id: z.string().max(100).optional().describe('Custom task ID (auto-generated if omitted)'),
176
+ }),
177
+ annotations: {
178
+ readOnlyHint: false,
179
+ destructiveHint: false,
180
+ idempotentHint: false,
181
+ openWorldHint: false,
182
+ },
183
+ },
184
+ async ({ title, description, priority, id }) => {
185
+ try {
186
+ const { db } = getContext();
187
+ const taskId = createTask(db, { id, title, description, priority });
188
+ db.close();
189
+
190
+ const result = { task_id: taskId, title, priority };
191
+ return toolOk(JSON.stringify(result, null, 2), result);
192
+ } catch (err) {
193
+ return toolError(err.message);
194
+ }
195
+ },
196
+ );
197
+
198
+ // ── switchman_task_claim ───────────────────────────────────────────────────────
199
+
200
+ server.registerTool(
201
+ 'switchman_task_claim',
202
+ {
203
+ title: 'Claim Files for a Task',
204
+ description: `Declares which files this agent intends to edit for a task.
205
+
206
+ Call this immediately after receiving a task, before making any edits. Switchman checks whether any of the files are already claimed by another active task in a different worktree and warns you before a conflict can occur.
207
+
208
+ Args:
209
+ - task_id (string): The task ID returned by switchman_task_next or switchman_task_add
210
+ - worktree (string): Your git worktree name
211
+ - files (array of strings): File paths you plan to edit, relative to repo root (e.g. ["src/auth/login.js", "tests/auth.test.js"])
212
+ - agent (string, optional): Agent identifier for logging
213
+ - force (boolean, optional): If true, claim even if conflicts exist (default: false)
214
+
215
+ Returns JSON:
216
+ {
217
+ "claimed": string[], // Files successfully claimed
218
+ "conflicts": [ // Files already claimed by other worktrees
219
+ {
220
+ "file": string,
221
+ "claimed_by_worktree": string,
222
+ "claimed_by_task": string
223
+ }
224
+ ],
225
+ "safe_to_proceed": boolean // true if no conflicts (or force=true)
226
+ }
227
+
228
+ Examples:
229
+ - Before editing auth files: files: ["src/auth/login.js", "src/auth/token.js"]
230
+ - If conflicts returned: coordinate with other agents or choose different files
231
+ - Do NOT use force=true unless you've confirmed the conflict is safe to override`,
232
+ inputSchema: z.object({
233
+ task_id: z.string().min(1).describe('Task ID from switchman_task_next'),
234
+ worktree: z.string().min(1).describe('Your git worktree name'),
235
+ files: z.array(z.string().min(1)).min(1).max(500).describe('File paths to claim, relative to repo root'),
236
+ agent: z.string().optional().describe('Agent identifier for logging'),
237
+ force: z.boolean().default(false).describe('Claim even if conflicts exist (use with caution)'),
238
+ }),
239
+ annotations: {
240
+ readOnlyHint: false,
241
+ destructiveHint: false,
242
+ idempotentHint: true,
243
+ openWorldHint: false,
244
+ },
245
+ },
246
+ async ({ task_id, worktree, files, agent, force }) => {
247
+ try {
248
+ const { db } = getContext();
249
+
250
+ // Check for conflicts first
251
+ const conflicts = checkFileConflicts(db, files, worktree);
252
+
253
+ if (conflicts.length > 0 && !force) {
254
+ db.close();
255
+ const result = {
256
+ claimed: [],
257
+ conflicts: conflicts.map((c) => ({
258
+ file: c.file,
259
+ claimed_by_worktree: c.claimedBy.worktree,
260
+ claimed_by_task: c.claimedBy.task_title,
261
+ })),
262
+ safe_to_proceed: false,
263
+ };
264
+ return toolOk(
265
+ `Conflicts detected — ${conflicts.length} file(s) already claimed by other worktrees.\n` +
266
+ JSON.stringify(result, null, 2),
267
+ result,
268
+ );
269
+ }
270
+
271
+ claimFiles(db, task_id, worktree, files, agent ?? null);
272
+ db.close();
273
+
274
+ const result = {
275
+ claimed: files,
276
+ conflicts: conflicts.map((c) => ({
277
+ file: c.file,
278
+ claimed_by_worktree: c.claimedBy.worktree,
279
+ claimed_by_task: c.claimedBy.task_title,
280
+ })),
281
+ safe_to_proceed: true,
282
+ };
283
+ return toolOk(
284
+ `Claimed ${files.length} file(s) for task ${task_id}.\n` + JSON.stringify(result, null, 2),
285
+ result,
286
+ );
287
+ } catch (err) {
288
+ return toolError(err.message);
289
+ }
290
+ },
291
+ );
292
+
293
+ // ── switchman_task_done ────────────────────────────────────────────────────────
294
+
295
+ server.registerTool(
296
+ 'switchman_task_done',
297
+ {
298
+ title: 'Mark Task Complete',
299
+ description: `Marks a task as done and always releases all file claims so other agents can pick up those files.
300
+
301
+ Call this when you have finished your implementation and committed your changes.
302
+
303
+ Args:
304
+ - task_id (string): The task ID to complete
305
+
306
+ Returns JSON:
307
+ {
308
+ "task_id": string,
309
+ "status": "done",
310
+ "files_released": true
311
+ }`,
312
+ inputSchema: z.object({
313
+ task_id: z.string().min(1).describe('The task ID to mark complete'),
314
+ }),
315
+ annotations: {
316
+ readOnlyHint: false,
317
+ destructiveHint: false,
318
+ idempotentHint: true,
319
+ openWorldHint: false,
320
+ },
321
+ },
322
+ async ({ task_id }) => {
323
+ try {
324
+ const { db } = getContext();
325
+ completeTask(db, task_id);
326
+ releaseFileClaims(db, task_id);
327
+ db.close();
328
+
329
+ const result = { task_id, status: 'done', files_released: true };
330
+ return toolOk(JSON.stringify(result, null, 2), result);
331
+ } catch (err) {
332
+ return toolError(err.message);
333
+ }
334
+ },
335
+ );
336
+
337
+ // ── switchman_task_fail ────────────────────────────────────────────────────────
338
+
339
+ server.registerTool(
340
+ 'switchman_task_fail',
341
+ {
342
+ title: 'Mark Task Failed',
343
+ description: `Marks a task as failed, records the reason, and releases all file claims.
344
+
345
+ Call this if you cannot complete the task — the task will be visible in the queue as failed so a human can review it. File claims are always released on failure so other agents aren't blocked.
346
+
347
+ Args:
348
+ - task_id (string): The task ID to mark as failed
349
+ - reason (string): Brief explanation of why the task failed
350
+
351
+ Returns JSON:
352
+ {
353
+ "task_id": string,
354
+ "status": "failed",
355
+ "reason": string,
356
+ "files_released": true
357
+ }`,
358
+ inputSchema: z.object({
359
+ task_id: z.string().min(1).describe('The task ID to mark as failed'),
360
+ reason: z.string().min(1).max(500).describe('Brief explanation of why the task failed'),
361
+ }),
362
+ annotations: {
363
+ readOnlyHint: false,
364
+ destructiveHint: false,
365
+ idempotentHint: true,
366
+ openWorldHint: false,
367
+ },
368
+ },
369
+ async ({ task_id, reason }) => {
370
+ try {
371
+ const { db } = getContext();
372
+ failTask(db, task_id, reason);
373
+ releaseFileClaims(db, task_id);
374
+ db.close();
375
+
376
+ const result = { task_id, status: 'failed', reason, files_released: true };
377
+ return toolOk(JSON.stringify(result, null, 2), result);
378
+ } catch (err) {
379
+ return toolError(err.message);
380
+ }
381
+ },
382
+ );
383
+
384
+ // ── switchman_scan ─────────────────────────────────────────────────────────────
385
+
386
+ server.registerTool(
387
+ 'switchman_scan',
388
+ {
389
+ title: 'Scan for Conflicts',
390
+ description: `Scans all git worktrees for conflicts — both uncommitted file overlaps and branch-level merge conflicts.
391
+
392
+ Run this before starting work on a task, before merging a branch, or any time you want to verify the workspace is clean. This is a read-only operation that never modifies any files.
393
+
394
+ Two detection layers:
395
+ 1. Uncommitted file overlaps — files being edited in multiple worktrees right now
396
+ 2. Branch-level merge conflicts — branches that would conflict when merged (uses git merge-tree)
397
+
398
+ Args:
399
+ - (none required)
400
+
401
+ Returns JSON:
402
+ {
403
+ "worktrees": [{ "name": string, "branch": string, "changed_files": number }],
404
+ "file_conflicts": [ // Files touched in multiple worktrees
405
+ { "file": string, "worktrees": string[] }
406
+ ],
407
+ "branch_conflicts": [ // Branches with merge conflicts
408
+ {
409
+ "type": "merge_conflict" | "file_overlap",
410
+ "worktree_a": string, "branch_a": string,
411
+ "worktree_b": string, "branch_b": string,
412
+ "conflicting_files": string[]
413
+ }
414
+ ],
415
+ "safe_to_proceed": boolean, // true when no conflicts found
416
+ "summary": string
417
+ }
418
+
419
+ Examples:
420
+ - Before editing files: ensure safe_to_proceed is true
421
+ - If file_conflicts found: coordinate with agents in other worktrees
422
+ - If branch_conflicts found: resolve before merging to main`,
423
+ inputSchema: z.object({}),
424
+ annotations: {
425
+ readOnlyHint: true,
426
+ destructiveHint: false,
427
+ idempotentHint: true,
428
+ openWorldHint: false,
429
+ },
430
+ },
431
+ async () => {
432
+ try {
433
+ const { repoRoot, db } = getContext();
434
+ const report = await scanAllWorktrees(db, repoRoot);
435
+ db.close();
436
+
437
+ const result = {
438
+ worktrees: report.worktrees.map((wt) => ({
439
+ name: wt.name,
440
+ branch: wt.branch ?? 'unknown',
441
+ changed_files: (report.fileMap?.[wt.name] ?? []).length,
442
+ })),
443
+ file_conflicts: report.fileConflicts,
444
+ branch_conflicts: report.conflicts.map((c) => ({
445
+ type: c.type,
446
+ worktree_a: c.worktreeA,
447
+ branch_a: c.branchA,
448
+ worktree_b: c.worktreeB,
449
+ branch_b: c.branchB,
450
+ conflicting_files: c.conflictingFiles,
451
+ })),
452
+ safe_to_proceed: report.conflicts.length === 0 && report.fileConflicts.length === 0,
453
+ summary: report.summary,
454
+ };
455
+ return toolOk(JSON.stringify(result, null, 2), result);
456
+ } catch (err) {
457
+ return toolError(`Scan failed: ${err.message}. Ensure switchman is initialised ('switchman init').`);
458
+ }
459
+ },
460
+ );
461
+
462
+ // ── switchman_status ───────────────────────────────────────────────────────────
463
+
464
+ server.registerTool(
465
+ 'switchman_status',
466
+ {
467
+ title: 'Get System Status',
468
+ description: `Returns a full overview of the Switchman coordination state: task queue counts, active tasks, file claims, and worktree list.
469
+
470
+ Use this to understand the current state before starting work, or to check what other agents are doing.
471
+
472
+ Args:
473
+ - (none required)
474
+
475
+ Returns JSON:
476
+ {
477
+ "tasks": {
478
+ "pending": number,
479
+ "in_progress": number,
480
+ "done": number,
481
+ "failed": number,
482
+ "active": [{ "id": string, "title": string, "worktree": string, "priority": number }]
483
+ },
484
+ "file_claims": {
485
+ "total_active": number,
486
+ "by_worktree": { [worktree: string]: string[] } // worktree -> files
487
+ },
488
+ "worktrees": [{ "name": string, "branch": string, "agent": string | null, "status": string }],
489
+ "repo_root": string
490
+ }`,
491
+ inputSchema: z.object({}),
492
+ annotations: {
493
+ readOnlyHint: true,
494
+ destructiveHint: false,
495
+ idempotentHint: true,
496
+ openWorldHint: false,
497
+ },
498
+ },
499
+ async () => {
500
+ try {
501
+ const { repoRoot, db } = getContext();
502
+
503
+ const tasks = listTasks(db);
504
+ const claims = getActiveFileClaims(db);
505
+ const worktrees = listWorktrees(db);
506
+ db.close();
507
+
508
+ const byWorktree = {};
509
+ for (const c of claims) {
510
+ if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
511
+ byWorktree[c.worktree].push(c.file_path);
512
+ }
513
+
514
+ const result = {
515
+ tasks: {
516
+ pending: tasks.filter((t) => t.status === 'pending').length,
517
+ in_progress: tasks.filter((t) => t.status === 'in_progress').length,
518
+ done: tasks.filter((t) => t.status === 'done').length,
519
+ failed: tasks.filter((t) => t.status === 'failed').length,
520
+ active: tasks
521
+ .filter((t) => t.status === 'in_progress')
522
+ .map((t) => ({ id: t.id, title: t.title, worktree: t.worktree, priority: t.priority })),
523
+ },
524
+ file_claims: {
525
+ total_active: claims.length,
526
+ by_worktree: byWorktree,
527
+ },
528
+ worktrees: worktrees.map((wt) => ({
529
+ name: wt.name,
530
+ branch: wt.branch,
531
+ agent: wt.agent ?? null,
532
+ status: wt.status,
533
+ })),
534
+ repo_root: repoRoot,
535
+ };
536
+ return toolOk(JSON.stringify(result, null, 2), result);
537
+ } catch (err) {
538
+ return toolError(err.message);
539
+ }
540
+ },
541
+ );
542
+
543
+ // ─── Transport ────────────────────────────────────────────────────────────────
544
+
545
+ async function main() {
546
+ const transport = new StdioServerTransport();
547
+ await server.connect(transport);
548
+ // stdio servers must not write to stdout — all logging goes to stderr
549
+ process.stderr.write('switchman MCP server running (stdio)\n');
550
+ }
551
+
552
+ main().catch((err) => {
553
+ process.stderr.write(`switchman MCP server fatal error: ${err.message}\n`);
554
+ process.exit(1);
555
+ });