switchman-dev 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/server.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * switchman_task_claim — claim files for a task (conflict-safe)
12
12
  * switchman_task_done — mark a task complete + release file claims
13
13
  * switchman_task_fail — mark a task failed + release file claims
14
+ * switchman_lease_heartbeat — refresh a lease heartbeat
14
15
  * switchman_scan — scan all worktrees for conflicts right now
15
16
  * switchman_status — full system overview (tasks, claims, worktrees)
16
17
  */
@@ -23,12 +24,15 @@ import { findRepoRoot } from '../core/git.js';
23
24
  import {
24
25
  openDb,
25
26
  createTask,
26
- assignTask,
27
+ startTaskLease,
27
28
  completeTask,
28
29
  failTask,
29
30
  listTasks,
30
- getTask,
31
31
  getNextPendingTask,
32
+ listLeases,
33
+ getActiveLeaseForTask,
34
+ heartbeatLease,
35
+ getStaleLeases,
32
36
  claimFiles,
33
37
  releaseFileClaims,
34
38
  getActiveFileClaims,
@@ -36,6 +40,8 @@ import {
36
40
  listWorktrees,
37
41
  } from '../core/db.js';
38
42
  import { scanAllWorktrees } from '../core/detector.js';
43
+ import { gatewayAppendFile, gatewayMakeDirectory, gatewayMovePath, gatewayRemovePath, gatewayWriteFile, monitorWorktreesOnce } from '../core/enforcement.js';
44
+ import { runAiMergeGate } from '../core/merge-gate.js';
39
45
 
40
46
  // ─── Helpers ──────────────────────────────────────────────────────────────────
41
47
 
@@ -95,7 +101,10 @@ Returns JSON:
95
101
  "description": string | null,
96
102
  "priority": number, // 1-10, higher = more urgent
97
103
  "worktree": string,
98
- "status": "in_progress"
104
+ "status": "in_progress",
105
+ "lease_id": string, // Active lease/session ID for the task
106
+ "lease_status": "active",
107
+ "heartbeat_at": string
99
108
  } | null // null when queue is empty
100
109
  }
101
110
 
@@ -124,17 +133,27 @@ Examples:
124
133
  return toolOk(JSON.stringify({ task: null }), { task: null });
125
134
  }
126
135
 
127
- const assigned = assignTask(db, task.id, worktree, agent ?? null);
128
- db.close();
136
+ const lease = startTaskLease(db, task.id, worktree, agent ?? null);
129
137
 
130
- if (!assigned) {
138
+ if (!lease) {
139
+ db.close();
131
140
  // Race condition: another agent grabbed it first — try again
132
141
  return toolOk(
133
142
  JSON.stringify({ task: null, message: 'Task was claimed by another agent. Call switchman_task_next again.' }),
134
143
  );
135
144
  }
136
145
 
137
- const result = { task: { ...task, worktree, status: 'in_progress' } };
146
+ db.close();
147
+ const result = {
148
+ task: {
149
+ ...task,
150
+ worktree,
151
+ status: 'in_progress',
152
+ lease_id: lease.id,
153
+ lease_status: lease.status,
154
+ heartbeat_at: lease.heartbeat_at,
155
+ },
156
+ };
138
157
  return toolOk(JSON.stringify(result, null, 2), result);
139
158
  } catch (err) {
140
159
  return toolError(`${err.message}. Make sure switchman is initialised in this repo (run 'switchman init').`);
@@ -210,16 +229,20 @@ Args:
210
229
  - worktree (string): Your git worktree name
211
230
  - 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
231
  - agent (string, optional): Agent identifier for logging
232
+ - lease_id (string, optional): Active lease ID returned by switchman_task_next
213
233
  - force (boolean, optional): If true, claim even if conflicts exist (default: false)
214
234
 
215
235
  Returns JSON:
216
236
  {
237
+ "lease_id": string,
217
238
  "claimed": string[], // Files successfully claimed
218
239
  "conflicts": [ // Files already claimed by other worktrees
219
240
  {
220
241
  "file": string,
242
+ "claimed_by_task_id": string,
221
243
  "claimed_by_worktree": string,
222
- "claimed_by_task": string
244
+ "claimed_by_task": string,
245
+ "claimed_by_lease_id": string | null
223
246
  }
224
247
  ],
225
248
  "safe_to_proceed": boolean // true if no conflicts (or force=true)
@@ -234,6 +257,7 @@ Examples:
234
257
  worktree: z.string().min(1).describe('Your git worktree name'),
235
258
  files: z.array(z.string().min(1)).min(1).max(500).describe('File paths to claim, relative to repo root'),
236
259
  agent: z.string().optional().describe('Agent identifier for logging'),
260
+ lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
237
261
  force: z.boolean().default(false).describe('Claim even if conflicts exist (use with caution)'),
238
262
  }),
239
263
  annotations: {
@@ -243,9 +267,10 @@ Examples:
243
267
  openWorldHint: false,
244
268
  },
245
269
  },
246
- async ({ task_id, worktree, files, agent, force }) => {
270
+ async ({ task_id, worktree, files, agent, lease_id, force }) => {
271
+ let db;
247
272
  try {
248
- const { db } = getContext();
273
+ ({ db } = getContext());
249
274
 
250
275
  // Check for conflicts first
251
276
  const conflicts = checkFileConflicts(db, files, worktree);
@@ -256,8 +281,10 @@ Examples:
256
281
  claimed: [],
257
282
  conflicts: conflicts.map((c) => ({
258
283
  file: c.file,
284
+ claimed_by_task_id: c.claimedBy.task_id,
259
285
  claimed_by_worktree: c.claimedBy.worktree,
260
286
  claimed_by_task: c.claimedBy.task_title,
287
+ claimed_by_lease_id: c.claimedBy.lease_id ?? null,
261
288
  })),
262
289
  safe_to_proceed: false,
263
290
  };
@@ -268,15 +295,20 @@ Examples:
268
295
  );
269
296
  }
270
297
 
271
- claimFiles(db, task_id, worktree, files, agent ?? null);
272
- db.close();
298
+ const lease = claimFiles(db, task_id, worktree, files, agent ?? null);
299
+ if (lease_id && lease.id !== lease_id) {
300
+ return toolError(`Task ${task_id} is active under lease ${lease.id}, not ${lease_id}.`);
301
+ }
273
302
 
274
303
  const result = {
304
+ lease_id: lease.id,
275
305
  claimed: files,
276
306
  conflicts: conflicts.map((c) => ({
277
307
  file: c.file,
308
+ claimed_by_task_id: c.claimedBy.task_id,
278
309
  claimed_by_worktree: c.claimedBy.worktree,
279
310
  claimed_by_task: c.claimedBy.task_title,
311
+ claimed_by_lease_id: c.claimedBy.lease_id ?? null,
280
312
  })),
281
313
  safe_to_proceed: true,
282
314
  };
@@ -286,6 +318,275 @@ Examples:
286
318
  );
287
319
  } catch (err) {
288
320
  return toolError(err.message);
321
+ } finally {
322
+ db?.close();
323
+ }
324
+ },
325
+ );
326
+
327
+ // ── switchman_write_file ──────────────────────────────────────────────────────
328
+
329
+ server.registerTool(
330
+ 'switchman_write_file',
331
+ {
332
+ title: 'Write File Through Switchman',
333
+ description: `Replaces a file's contents through the Switchman write gateway.
334
+
335
+ Use this instead of direct filesystem writes when the agent is running in managed mode. The target path must already be claimed by the active lease.
336
+
337
+ Args:
338
+ - lease_id (string): Active lease ID returned by switchman_task_next
339
+ - path (string): Target file path, relative to repo root
340
+ - content (string): Full replacement content
341
+ - worktree (string, optional): Expected worktree name for extra validation
342
+
343
+ Returns JSON:
344
+ {
345
+ "ok": boolean,
346
+ "file_path": string,
347
+ "lease_id": string,
348
+ "bytes_written": number
349
+ }`,
350
+ inputSchema: z.object({
351
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
352
+ path: z.string().min(1).describe('Target file path relative to repo root'),
353
+ content: z.string().describe('Full replacement content'),
354
+ worktree: z.string().optional().describe('Optional worktree name for validation'),
355
+ }),
356
+ annotations: {
357
+ readOnlyHint: false,
358
+ destructiveHint: false,
359
+ idempotentHint: false,
360
+ openWorldHint: false,
361
+ },
362
+ },
363
+ async ({ lease_id, path, content, worktree }) => {
364
+ try {
365
+ const { repoRoot, db } = getContext();
366
+ const result = gatewayWriteFile(db, repoRoot, {
367
+ leaseId: lease_id,
368
+ path,
369
+ content,
370
+ worktree: worktree ?? null,
371
+ });
372
+ db.close();
373
+
374
+ if (!result.ok) {
375
+ return toolError(`Write denied for ${result.file_path ?? path}: ${result.reason_code}.`);
376
+ }
377
+
378
+ return toolOk(JSON.stringify(result, null, 2), result);
379
+ } catch (err) {
380
+ return toolError(err.message);
381
+ }
382
+ },
383
+ );
384
+
385
+ // ── switchman_remove_path ─────────────────────────────────────────────────────
386
+
387
+ server.registerTool(
388
+ 'switchman_remove_path',
389
+ {
390
+ title: 'Remove Path Through Switchman',
391
+ description: `Removes a file or directory through the Switchman write gateway.
392
+
393
+ Use this instead of direct filesystem deletion when the agent is running in managed mode. The target path must already be claimed by the active lease.
394
+
395
+ Args:
396
+ - lease_id (string): Active lease ID returned by switchman_task_next
397
+ - path (string): Target file or directory path, relative to repo root
398
+ - worktree (string, optional): Expected worktree name for extra validation
399
+
400
+ Returns JSON:
401
+ {
402
+ "ok": boolean,
403
+ "file_path": string,
404
+ "lease_id": string,
405
+ "removed": true
406
+ }`,
407
+ inputSchema: z.object({
408
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
409
+ path: z.string().min(1).describe('Target path relative to repo root'),
410
+ worktree: z.string().optional().describe('Optional worktree name for validation'),
411
+ }),
412
+ annotations: {
413
+ readOnlyHint: false,
414
+ destructiveHint: true,
415
+ idempotentHint: true,
416
+ openWorldHint: false,
417
+ },
418
+ },
419
+ async ({ lease_id, path, worktree }) => {
420
+ try {
421
+ const { repoRoot, db } = getContext();
422
+ const result = gatewayRemovePath(db, repoRoot, {
423
+ leaseId: lease_id,
424
+ path,
425
+ worktree: worktree ?? null,
426
+ });
427
+ db.close();
428
+
429
+ if (!result.ok) {
430
+ return toolError(`Remove denied for ${result.file_path ?? path}: ${result.reason_code}.`);
431
+ }
432
+
433
+ return toolOk(JSON.stringify(result, null, 2), result);
434
+ } catch (err) {
435
+ return toolError(err.message);
436
+ }
437
+ },
438
+ );
439
+
440
+ // ── switchman_append_file ─────────────────────────────────────────────────────
441
+
442
+ server.registerTool(
443
+ 'switchman_append_file',
444
+ {
445
+ title: 'Append File Through Switchman',
446
+ description: `Appends content to a claimed file through the Switchman write gateway.`,
447
+ inputSchema: z.object({
448
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
449
+ path: z.string().min(1).describe('Target file path relative to repo root'),
450
+ content: z.string().describe('Content to append'),
451
+ worktree: z.string().optional().describe('Optional worktree name for validation'),
452
+ }),
453
+ annotations: {
454
+ readOnlyHint: false,
455
+ destructiveHint: false,
456
+ idempotentHint: false,
457
+ openWorldHint: false,
458
+ },
459
+ },
460
+ async ({ lease_id, path, content, worktree }) => {
461
+ try {
462
+ const { repoRoot, db } = getContext();
463
+ const result = gatewayAppendFile(db, repoRoot, {
464
+ leaseId: lease_id,
465
+ path,
466
+ content,
467
+ worktree: worktree ?? null,
468
+ });
469
+ db.close();
470
+
471
+ if (!result.ok) {
472
+ return toolError(`Append denied for ${result.file_path ?? path}: ${result.reason_code}.`);
473
+ }
474
+
475
+ return toolOk(JSON.stringify(result, null, 2), result);
476
+ } catch (err) {
477
+ return toolError(err.message);
478
+ }
479
+ },
480
+ );
481
+
482
+ // ── switchman_move_path ───────────────────────────────────────────────────────
483
+
484
+ server.registerTool(
485
+ 'switchman_move_path',
486
+ {
487
+ title: 'Move Path Through Switchman',
488
+ description: `Moves a claimed file to another claimed path through the Switchman write gateway.`,
489
+ inputSchema: z.object({
490
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
491
+ source_path: z.string().min(1).describe('Source file path relative to repo root'),
492
+ destination_path: z.string().min(1).describe('Destination file path relative to repo root'),
493
+ worktree: z.string().optional().describe('Optional worktree name for validation'),
494
+ }),
495
+ annotations: {
496
+ readOnlyHint: false,
497
+ destructiveHint: true,
498
+ idempotentHint: false,
499
+ openWorldHint: false,
500
+ },
501
+ },
502
+ async ({ lease_id, source_path, destination_path, worktree }) => {
503
+ try {
504
+ const { repoRoot, db } = getContext();
505
+ const result = gatewayMovePath(db, repoRoot, {
506
+ leaseId: lease_id,
507
+ sourcePath: source_path,
508
+ destinationPath: destination_path,
509
+ worktree: worktree ?? null,
510
+ });
511
+ db.close();
512
+
513
+ if (!result.ok) {
514
+ return toolError(`Move denied for ${result.file_path ?? destination_path}: ${result.reason_code}.`);
515
+ }
516
+
517
+ return toolOk(JSON.stringify(result, null, 2), result);
518
+ } catch (err) {
519
+ return toolError(err.message);
520
+ }
521
+ },
522
+ );
523
+
524
+ // ── switchman_make_directory ──────────────────────────────────────────────────
525
+
526
+ server.registerTool(
527
+ 'switchman_make_directory',
528
+ {
529
+ title: 'Create Directory Through Switchman',
530
+ description: `Creates a directory through the Switchman write gateway when it is part of a claimed destination path.`,
531
+ inputSchema: z.object({
532
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
533
+ path: z.string().min(1).describe('Directory path relative to repo root'),
534
+ worktree: z.string().optional().describe('Optional worktree name for validation'),
535
+ }),
536
+ annotations: {
537
+ readOnlyHint: false,
538
+ destructiveHint: false,
539
+ idempotentHint: true,
540
+ openWorldHint: false,
541
+ },
542
+ },
543
+ async ({ lease_id, path, worktree }) => {
544
+ try {
545
+ const { repoRoot, db } = getContext();
546
+ const result = gatewayMakeDirectory(db, repoRoot, {
547
+ leaseId: lease_id,
548
+ path,
549
+ worktree: worktree ?? null,
550
+ });
551
+ db.close();
552
+
553
+ if (!result.ok) {
554
+ return toolError(`Mkdir denied for ${result.file_path ?? path}: ${result.reason_code}.`);
555
+ }
556
+
557
+ return toolOk(JSON.stringify(result, null, 2), result);
558
+ } catch (err) {
559
+ return toolError(err.message);
560
+ }
561
+ },
562
+ );
563
+
564
+ // ── switchman_monitor_once ────────────────────────────────────────────────────
565
+
566
+ server.registerTool(
567
+ 'switchman_monitor_once',
568
+ {
569
+ title: 'Observe Runtime File Changes Once',
570
+ description: `Runs one filesystem monitoring pass across all registered worktrees.
571
+
572
+ This compares the current worktree file state against the previous Switchman snapshot, logs observed mutations, and classifies them as allowed or denied based on the active lease and claims.`,
573
+ inputSchema: z.object({}),
574
+ annotations: {
575
+ readOnlyHint: false,
576
+ destructiveHint: false,
577
+ idempotentHint: false,
578
+ openWorldHint: false,
579
+ },
580
+ },
581
+ async () => {
582
+ try {
583
+ const { repoRoot, db } = getContext();
584
+ const worktrees = listWorktrees(db);
585
+ const result = monitorWorktreesOnce(db, repoRoot, worktrees);
586
+ db.close();
587
+ return toolOk(JSON.stringify(result, null, 2), result);
588
+ } catch (err) {
589
+ return toolError(err.message);
289
590
  }
290
591
  },
291
592
  );
@@ -302,15 +603,18 @@ Call this when you have finished your implementation and committed your changes.
302
603
 
303
604
  Args:
304
605
  - task_id (string): The task ID to complete
606
+ - lease_id (string, optional): Active lease ID returned by switchman_task_next
305
607
 
306
608
  Returns JSON:
307
609
  {
308
610
  "task_id": string,
611
+ "lease_id": string | null,
309
612
  "status": "done",
310
613
  "files_released": true
311
614
  }`,
312
615
  inputSchema: z.object({
313
616
  task_id: z.string().min(1).describe('The task ID to mark complete'),
617
+ lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
314
618
  }),
315
619
  annotations: {
316
620
  readOnlyHint: false,
@@ -319,14 +623,19 @@ Returns JSON:
319
623
  openWorldHint: false,
320
624
  },
321
625
  },
322
- async ({ task_id }) => {
626
+ async ({ task_id, lease_id }) => {
323
627
  try {
324
628
  const { db } = getContext();
629
+ const activeLease = getActiveLeaseForTask(db, task_id);
630
+ if (lease_id && activeLease && activeLease.id !== lease_id) {
631
+ db.close();
632
+ return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
633
+ }
325
634
  completeTask(db, task_id);
326
635
  releaseFileClaims(db, task_id);
327
636
  db.close();
328
637
 
329
- const result = { task_id, status: 'done', files_released: true };
638
+ const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'done', files_released: true };
330
639
  return toolOk(JSON.stringify(result, null, 2), result);
331
640
  } catch (err) {
332
641
  return toolError(err.message);
@@ -346,17 +655,20 @@ Call this if you cannot complete the task — the task will be visible in the qu
346
655
 
347
656
  Args:
348
657
  - task_id (string): The task ID to mark as failed
658
+ - lease_id (string, optional): Active lease ID returned by switchman_task_next
349
659
  - reason (string): Brief explanation of why the task failed
350
660
 
351
661
  Returns JSON:
352
662
  {
353
663
  "task_id": string,
664
+ "lease_id": string | null,
354
665
  "status": "failed",
355
666
  "reason": string,
356
667
  "files_released": true
357
668
  }`,
358
669
  inputSchema: z.object({
359
670
  task_id: z.string().min(1).describe('The task ID to mark as failed'),
671
+ lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
360
672
  reason: z.string().min(1).max(500).describe('Brief explanation of why the task failed'),
361
673
  }),
362
674
  annotations: {
@@ -366,14 +678,74 @@ Returns JSON:
366
678
  openWorldHint: false,
367
679
  },
368
680
  },
369
- async ({ task_id, reason }) => {
681
+ async ({ task_id, lease_id, reason }) => {
370
682
  try {
371
683
  const { db } = getContext();
684
+ const activeLease = getActiveLeaseForTask(db, task_id);
685
+ if (lease_id && activeLease && activeLease.id !== lease_id) {
686
+ db.close();
687
+ return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
688
+ }
372
689
  failTask(db, task_id, reason);
373
690
  releaseFileClaims(db, task_id);
374
691
  db.close();
375
692
 
376
- const result = { task_id, status: 'failed', reason, files_released: true };
693
+ const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'failed', reason, files_released: true };
694
+ return toolOk(JSON.stringify(result, null, 2), result);
695
+ } catch (err) {
696
+ return toolError(err.message);
697
+ }
698
+ },
699
+ );
700
+
701
+ // ── switchman_lease_heartbeat ─────────────────────────────────────────────────
702
+
703
+ server.registerTool(
704
+ 'switchman_lease_heartbeat',
705
+ {
706
+ title: 'Refresh Lease Heartbeat',
707
+ description: `Refreshes the heartbeat timestamp for an active lease.
708
+
709
+ Call this periodically while an agent is still working on a task so stale-session reaping does not recycle the task prematurely.
710
+
711
+ Args:
712
+ - lease_id (string): Active lease ID returned by switchman_task_next
713
+ - agent (string, optional): Agent identifier to attach to the refreshed lease
714
+
715
+ Returns JSON:
716
+ {
717
+ "lease_id": string,
718
+ "task_id": string,
719
+ "worktree": string,
720
+ "heartbeat_at": string
721
+ }`,
722
+ inputSchema: z.object({
723
+ lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
724
+ agent: z.string().optional().describe('Agent identifier for logging'),
725
+ }),
726
+ annotations: {
727
+ readOnlyHint: false,
728
+ destructiveHint: false,
729
+ idempotentHint: true,
730
+ openWorldHint: false,
731
+ },
732
+ },
733
+ async ({ lease_id, agent }) => {
734
+ try {
735
+ const { db } = getContext();
736
+ const lease = heartbeatLease(db, lease_id, agent ?? null);
737
+ db.close();
738
+
739
+ if (!lease) {
740
+ return toolError(`Lease ${lease_id} was not found or is no longer active.`);
741
+ }
742
+
743
+ const result = {
744
+ lease_id: lease.id,
745
+ task_id: lease.task_id,
746
+ worktree: lease.worktree,
747
+ heartbeat_at: lease.heartbeat_at,
748
+ };
377
749
  return toolOk(JSON.stringify(result, null, 2), result);
378
750
  } catch (err) {
379
751
  return toolError(err.message);
@@ -439,8 +811,10 @@ Examples:
439
811
  name: wt.name,
440
812
  branch: wt.branch ?? 'unknown',
441
813
  changed_files: (report.fileMap?.[wt.name] ?? []).length,
814
+ compliance_state: report.worktreeCompliance?.find((entry) => entry.worktree === wt.name)?.compliance_state ?? wt.compliance_state ?? 'observed',
442
815
  })),
443
816
  file_conflicts: report.fileConflicts,
817
+ unclaimed_changes: report.unclaimedChanges,
444
818
  branch_conflicts: report.conflicts.map((c) => ({
445
819
  type: c.type,
446
820
  worktree_a: c.worktreeA,
@@ -449,7 +823,8 @@ Examples:
449
823
  branch_b: c.branchB,
450
824
  conflicting_files: c.conflictingFiles,
451
825
  })),
452
- safe_to_proceed: report.conflicts.length === 0 && report.fileConflicts.length === 0,
826
+ compliance_summary: report.complianceSummary,
827
+ safe_to_proceed: report.conflicts.length === 0 && report.fileConflicts.length === 0 && report.unclaimedChanges.length === 0,
453
828
  summary: report.summary,
454
829
  };
455
830
  return toolOk(JSON.stringify(result, null, 2), result);
@@ -459,6 +834,52 @@ Examples:
459
834
  },
460
835
  );
461
836
 
837
+ // ── switchman_merge_gate ──────────────────────────────────────────────────────
838
+
839
+ server.registerTool(
840
+ 'switchman_merge_gate',
841
+ {
842
+ title: 'Run AI Merge Gate',
843
+ description: `Evaluates semantic merge risk across active worktrees using Switchman's local change graph.
844
+
845
+ This is an AI-style merge gate implemented as a deterministic local reviewer. It combines:
846
+ 1. Existing enforcement signals
847
+ 2. Exact file overlaps and git merge conflicts
848
+ 3. Shared subsystem overlap
849
+ 4. High-risk areas like auth, schema, config, and API changes
850
+ 5. Missing-test signals for source-heavy worktrees
851
+
852
+ Args:
853
+ - (none required)
854
+
855
+ Returns JSON:
856
+ {
857
+ "ok": boolean,
858
+ "status": "pass" | "warn" | "blocked",
859
+ "summary": string,
860
+ "worktrees": [{ "worktree": string, "score": number, "findings": string[] }],
861
+ "pairs": [{ "worktree_a": string, "worktree_b": string, "status": string, "score": number, "reasons": string[] }]
862
+ }`,
863
+ inputSchema: z.object({}),
864
+ annotations: {
865
+ readOnlyHint: true,
866
+ destructiveHint: false,
867
+ idempotentHint: true,
868
+ openWorldHint: false,
869
+ },
870
+ },
871
+ async () => {
872
+ try {
873
+ const { repoRoot, db } = getContext();
874
+ const result = await runAiMergeGate(db, repoRoot);
875
+ db.close();
876
+ return toolOk(JSON.stringify(result, null, 2), result);
877
+ } catch (err) {
878
+ return toolError(`AI merge gate failed: ${err.message}. Ensure switchman is initialised ('switchman init').`);
879
+ }
880
+ },
881
+ );
882
+
462
883
  // ── switchman_status ───────────────────────────────────────────────────────────
463
884
 
464
885
  server.registerTool(
@@ -479,13 +900,17 @@ Returns JSON:
479
900
  "in_progress": number,
480
901
  "done": number,
481
902
  "failed": number,
482
- "active": [{ "id": string, "title": string, "worktree": string, "priority": number }]
903
+ "active": [{ "id": string, "title": string, "worktree": string, "priority": number, "lease_id": string | null }]
483
904
  },
484
905
  "file_claims": {
485
906
  "total_active": number,
486
- "by_worktree": { [worktree: string]: string[] } // worktree -> files
907
+ "by_worktree": { [worktree: string]: { "file_path": string, "task_id": string, "lease_id": string | null }[] }
487
908
  },
488
- "worktrees": [{ "name": string, "branch": string, "agent": string | null, "status": string }],
909
+ "leases": {
910
+ "active": [{ "id": string, "task_id": string, "worktree": string, "agent": string | null, "heartbeat_at": string }],
911
+ "stale": [{ "id": string, "task_id": string, "worktree": string, "heartbeat_at": string }]
912
+ },
913
+ "worktrees": [{ "name": string, "branch": string, "agent": string | null, "status": string, "active_lease_id": string | null }],
489
914
  "repo_root": string
490
915
  }`,
491
916
  inputSchema: z.object({}),
@@ -503,14 +928,27 @@ Returns JSON:
503
928
  const tasks = listTasks(db);
504
929
  const claims = getActiveFileClaims(db);
505
930
  const worktrees = listWorktrees(db);
931
+ const leases = listLeases(db);
932
+ const staleLeases = getStaleLeases(db);
506
933
  db.close();
507
934
 
508
935
  const byWorktree = {};
509
936
  for (const c of claims) {
510
937
  if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
511
- byWorktree[c.worktree].push(c.file_path);
938
+ byWorktree[c.worktree].push({
939
+ file_path: c.file_path,
940
+ task_id: c.task_id,
941
+ lease_id: c.lease_id ?? null,
942
+ });
512
943
  }
513
944
 
945
+ const activeLeaseByTask = new Map(
946
+ leases.filter((lease) => lease.status === 'active').map((lease) => [lease.task_id, lease]),
947
+ );
948
+ const activeLeaseByWorktree = new Map(
949
+ leases.filter((lease) => lease.status === 'active').map((lease) => [lease.worktree, lease]),
950
+ );
951
+
514
952
  const result = {
515
953
  tasks: {
516
954
  pending: tasks.filter((t) => t.status === 'pending').length,
@@ -519,17 +957,47 @@ Returns JSON:
519
957
  failed: tasks.filter((t) => t.status === 'failed').length,
520
958
  active: tasks
521
959
  .filter((t) => t.status === 'in_progress')
522
- .map((t) => ({ id: t.id, title: t.title, worktree: t.worktree, priority: t.priority })),
960
+ .map((t) => ({
961
+ id: t.id,
962
+ title: t.title,
963
+ worktree: t.worktree,
964
+ priority: t.priority,
965
+ lease_id: activeLeaseByTask.get(t.id)?.id ?? null,
966
+ })),
523
967
  },
524
968
  file_claims: {
525
969
  total_active: claims.length,
526
970
  by_worktree: byWorktree,
971
+ claims: claims.map((claim) => ({
972
+ file_path: claim.file_path,
973
+ worktree: claim.worktree,
974
+ task_id: claim.task_id,
975
+ lease_id: claim.lease_id ?? null,
976
+ })),
977
+ },
978
+ leases: {
979
+ active: leases.filter((lease) => lease.status === 'active').map((lease) => ({
980
+ id: lease.id,
981
+ task_id: lease.task_id,
982
+ worktree: lease.worktree,
983
+ agent: lease.agent ?? null,
984
+ heartbeat_at: lease.heartbeat_at,
985
+ })),
986
+ stale: staleLeases.map((lease) => ({
987
+ id: lease.id,
988
+ task_id: lease.task_id,
989
+ worktree: lease.worktree,
990
+ heartbeat_at: lease.heartbeat_at,
991
+ })),
527
992
  },
528
993
  worktrees: worktrees.map((wt) => ({
529
994
  name: wt.name,
530
995
  branch: wt.branch,
531
996
  agent: wt.agent ?? null,
532
997
  status: wt.status,
998
+ enforcement_mode: wt.enforcement_mode ?? 'observed',
999
+ compliance_state: wt.compliance_state ?? 'observed',
1000
+ active_lease_id: activeLeaseByWorktree.get(wt.name)?.id ?? null,
533
1001
  })),
534
1002
  repo_root: repoRoot,
535
1003
  };
@@ -552,4 +1020,4 @@ async function main() {
552
1020
  main().catch((err) => {
553
1021
  process.stderr.write(`switchman MCP server fatal error: ${err.message}\n`);
554
1022
  process.exit(1);
555
- });
1023
+ });