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/README.md +327 -16
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +1 -1
- package/src/cli/index.js +2077 -216
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1848 -73
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +42 -5
- package/src/core/ignore.js +47 -0
- package/src/core/mcp.js +47 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +153 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +491 -23
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
|
-
|
|
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
|
|
128
|
-
db.close();
|
|
136
|
+
const lease = startTaskLease(db, task.id, worktree, agent ?? null);
|
|
129
137
|
|
|
130
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[] }
|
|
907
|
+
"by_worktree": { [worktree: string]: { "file_path": string, "task_id": string, "lease_id": string | null }[] }
|
|
487
908
|
},
|
|
488
|
-
"
|
|
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(
|
|
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) => ({
|
|
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
|
+
});
|