switchman-dev 0.1.2 → 0.1.4
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 +95 -205
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +3 -3
- package/src/cli/index.js +2517 -331
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1669 -28
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +108 -5
- package/src/core/ignore.js +49 -0
- package/src/core/mcp.js +76 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +190 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/policy.js +49 -0
- package/src/core/queue.js +225 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +321 -1
package/src/mcp/server.js
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
listWorktrees,
|
|
41
41
|
} from '../core/db.js';
|
|
42
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';
|
|
43
45
|
|
|
44
46
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
45
47
|
|
|
@@ -322,6 +324,273 @@ Examples:
|
|
|
322
324
|
},
|
|
323
325
|
);
|
|
324
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);
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
|
|
325
594
|
// ── switchman_task_done ────────────────────────────────────────────────────────
|
|
326
595
|
|
|
327
596
|
server.registerTool(
|
|
@@ -542,8 +811,10 @@ Examples:
|
|
|
542
811
|
name: wt.name,
|
|
543
812
|
branch: wt.branch ?? 'unknown',
|
|
544
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',
|
|
545
815
|
})),
|
|
546
816
|
file_conflicts: report.fileConflicts,
|
|
817
|
+
unclaimed_changes: report.unclaimedChanges,
|
|
547
818
|
branch_conflicts: report.conflicts.map((c) => ({
|
|
548
819
|
type: c.type,
|
|
549
820
|
worktree_a: c.worktreeA,
|
|
@@ -552,7 +823,8 @@ Examples:
|
|
|
552
823
|
branch_b: c.branchB,
|
|
553
824
|
conflicting_files: c.conflictingFiles,
|
|
554
825
|
})),
|
|
555
|
-
|
|
826
|
+
compliance_summary: report.complianceSummary,
|
|
827
|
+
safe_to_proceed: report.conflicts.length === 0 && report.fileConflicts.length === 0 && report.unclaimedChanges.length === 0,
|
|
556
828
|
summary: report.summary,
|
|
557
829
|
};
|
|
558
830
|
return toolOk(JSON.stringify(result, null, 2), result);
|
|
@@ -562,6 +834,52 @@ Examples:
|
|
|
562
834
|
},
|
|
563
835
|
);
|
|
564
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
|
+
|
|
565
883
|
// ── switchman_status ───────────────────────────────────────────────────────────
|
|
566
884
|
|
|
567
885
|
server.registerTool(
|
|
@@ -677,6 +995,8 @@ Returns JSON:
|
|
|
677
995
|
branch: wt.branch,
|
|
678
996
|
agent: wt.agent ?? null,
|
|
679
997
|
status: wt.status,
|
|
998
|
+
enforcement_mode: wt.enforcement_mode ?? 'observed',
|
|
999
|
+
compliance_state: wt.compliance_state ?? 'observed',
|
|
680
1000
|
active_lease_id: activeLeaseByWorktree.get(wt.name)?.id ?? null,
|
|
681
1001
|
})),
|
|
682
1002
|
repo_root: repoRoot,
|