@elyracode/swarm 0.9.18 → 0.9.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.20] - 2026-07-19
4
+
5
+ ### Added
6
+ - Worktree mode: `/swarm --worktrees <pipeline> <task>` (or `worktrees=true` on the `swarm` tool) runs every stage as an isolated sub-agent (`elyra -p`) inside a detached git worktree. Read-only stages are mechanically restricted to read/grep/find/ls tools, write stages cannot touch your working tree, and the combined result is applied uncommitted only after a conflict check against files that changed while the swarm ran. Falls back to the classic inline mode outside git repos
7
+
8
+ ## [0.9.19] - 2026-07-18
9
+
3
10
  ## [0.9.18] - 2026-07-18
4
11
 
5
12
  ## [0.9.17] - 2026-07-18
package/README.md CHANGED
@@ -59,6 +59,7 @@ analyze -> plan -> implement -> verify
59
59
  /swarm build add a notification system with email and in-app channels
60
60
  /swarm review the authentication module
61
61
  /swarm refactor the database query layer
62
+ /swarm --worktrees build add rate limiting to the API
62
63
  /swarm # lists available pipelines
63
64
  ```
64
65
 
@@ -76,3 +77,15 @@ Run a swarm review on the checkout flow
76
77
  Each pipeline stage runs as a focused agent turn with specific instructions. Results from each stage are passed to the next as context. The agent sees a progress indicator showing which stage is active.
77
78
 
78
79
  Stages marked as read-only (analyze, review, synthesize) cannot edit files. Only implementation stages (code, fix, implement) can write.
80
+
81
+ ## Worktree Mode
82
+
83
+ Add `--worktrees` (or pass `worktrees=true` to the `swarm` tool) to run every stage as an isolated sub-agent instead of inline turns:
84
+
85
+ - Each stage runs as its own `elyra -p` process inside a detached **git worktree** based on HEAD — your working tree is never touched while the swarm runs.
86
+ - Read-only stages are mechanically restricted to `read,grep,find,ls` tools, not just prompted.
87
+ - When all stages complete, the combined changes are checked for conflicts against files that changed in your working tree during the run. If clean, they are applied **uncommitted** so you can review them; if not, the worktree is kept and the conflicting paths are listed.
88
+ - On any failure the worktree is kept for inspection (`git worktree remove --force <dir>` to discard).
89
+ - Outside a git repository, worktree mode degrades gracefully to the classic inline mode.
90
+
91
+ Requirements: a git repository and the `elyra` CLI on PATH. Sub-agents start from HEAD, so uncommitted changes in your tree are not visible to them.
@@ -1,5 +1,14 @@
1
- import type { ExtensionAPI } from "@elyracode/coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@elyracode/coding-agent";
2
2
  import { Type } from "typebox";
3
+ import {
4
+ applyPatch,
5
+ changedFiles,
6
+ collectWorktreeChanges,
7
+ createWorktree,
8
+ findConflicts,
9
+ isGitRepo,
10
+ removeWorktree,
11
+ } from "./worktree.js";
3
12
 
4
13
  // ── Pipeline Definitions ────────────────────────────────────────────────────
5
14
 
@@ -263,6 +272,227 @@ function renderStageHeader(pipeline: Pipeline, stageIndex: number, task: string)
263
272
  }
264
273
 
265
274
  // ── Extension ───────────────────────────────────────────────────────────────
275
+ // ── Inline mode (prompt injection, original behavior) ───────────────────
276
+
277
+ function buildInlineResult(pipeline: Pipeline, task: string, note?: string) {
278
+ const completedIndices = new Set<number>();
279
+ const parts: string[] = [];
280
+
281
+ if (note) parts.push(`> ${note}\n\n`);
282
+
283
+ // Render initial pipeline view
284
+ parts.push(renderPipeline(pipeline, 0, completedIndices));
285
+
286
+ // Generate instructions for all stages
287
+ for (let i = 0; i < pipeline.stages.length; i++) {
288
+ const stage = pipeline.stages[i]!;
289
+ parts.push(renderStageHeader(pipeline, i, task));
290
+
291
+ parts.push(`**Task**: ${task}\n\n`);
292
+ parts.push(`**Instructions**:\n${stage.instructions}\n\n`);
293
+
294
+ if (stage.readOnly) {
295
+ parts.push(`**Constraint**: This is a read-only stage. Do NOT edit any files.\n\n`);
296
+ }
297
+
298
+ if (i < pipeline.stages.length - 1) {
299
+ parts.push(`When done, write your output under a "#### ${stage.label} Output" heading, then proceed to the next stage.\n\n`);
300
+ } else {
301
+ parts.push(`This is the final stage. Write your output under a "#### ${stage.label} Output" heading, then provide a "## Swarm Summary" with the overall result.\n\n`);
302
+ }
303
+
304
+ completedIndices.add(i);
305
+ }
306
+
307
+ // Final pipeline view (all complete)
308
+ parts.push("---\n\n");
309
+ parts.push(renderPipeline(pipeline, -1, completedIndices));
310
+
311
+ return {
312
+ content: [{ type: "text" as const, text: parts.join("") }],
313
+ details: {
314
+ pipeline: pipeline.name,
315
+ stages: pipeline.stages.length,
316
+ task,
317
+ },
318
+ };
319
+ }
320
+
321
+ // ── Worktree mode (isolated sub-agents) ───────────────────────────────
322
+
323
+ const STAGE_TIMEOUT_MS = 600_000;
324
+ const READ_ONLY_TOOLS = "read,grep,find,ls";
325
+
326
+ function buildStagePrompt(pipeline: Pipeline, stageIndex: number, task: string, previousOutputs: string[]): string {
327
+ const stage = pipeline.stages[stageIndex]!;
328
+ const parts: string[] = [];
329
+ parts.push(`You are one stage of the "${pipeline.name}" swarm pipeline.`);
330
+ parts.push(`\n\n**Task**: ${task}`);
331
+ parts.push(`\n\n**Your role**:\n${stage.instructions}`);
332
+ if (stage.readOnly) {
333
+ parts.push(`\n\n**Constraint**: This is a read-only stage. Do NOT edit any files.`);
334
+ }
335
+ if (previousOutputs.length > 0) {
336
+ parts.push(`\n\n**Output from previous stages**:\n\n${previousOutputs.join("\n\n---\n\n")}`);
337
+ }
338
+ parts.push(`\n\nWork directly in the current directory. When done, end with a concise summary of what you did/found.`);
339
+ return parts.join("");
340
+ }
341
+
342
+ async function runWorktreePipeline(
343
+ elyra: ExtensionAPI,
344
+ pipeline: Pipeline,
345
+ task: string,
346
+ ctx: ExtensionContext,
347
+ signal: AbortSignal | undefined,
348
+ onUpdate: ((partial: { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }) => void) | undefined,
349
+ ) {
350
+ const cwd = ctx.cwd;
351
+ const exec = elyra.exec.bind(elyra);
352
+
353
+ if (!(await isGitRepo(exec, cwd))) {
354
+ return buildInlineResult(pipeline, task, "Worktree mode requested but this is not a git repository — running inline instead.");
355
+ }
356
+
357
+ const mainBefore = await changedFiles(exec, cwd);
358
+ const worktree = await createWorktree(exec, cwd);
359
+ if ("error" in worktree) {
360
+ return buildInlineResult(pipeline, task, `Worktree mode requested but worktree creation failed (${worktree.error}) — running inline instead.`);
361
+ }
362
+
363
+ const modelArgs = ctx.model ? ["--model", `${ctx.model.provider}/${ctx.model.id}`] : [];
364
+ const completedIndices = new Set<number>();
365
+ const stageOutputs: string[] = [];
366
+ const transcript: string[] = [];
367
+ const dirtyNote =
368
+ mainBefore.size > 0
369
+ ? `Note: the main tree has ${mainBefore.size} uncommitted change(s). Sub-agents work from HEAD and cannot see them.\n\n`
370
+ : "";
371
+
372
+ const progress = (activeIndex: number) => {
373
+ onUpdate?.({
374
+ content: [{ type: "text", text: `${dirtyNote}${renderPipeline(pipeline, activeIndex, completedIndices)}` }],
375
+ details: { pipeline: pipeline.name, task, worktree: worktree.dir },
376
+ });
377
+ };
378
+
379
+ try {
380
+ for (let i = 0; i < pipeline.stages.length; i++) {
381
+ const stage = pipeline.stages[i]!;
382
+ progress(i);
383
+
384
+ const prompt = buildStagePrompt(pipeline, i, task, stageOutputs);
385
+ const args = [
386
+ "-p",
387
+ "--no-session",
388
+ ...modelArgs,
389
+ ...(stage.readOnly ? ["--tools", READ_ONLY_TOOLS] : []),
390
+ prompt,
391
+ ];
392
+
393
+ const result = await exec("elyra", args, { cwd: worktree.dir, timeout: STAGE_TIMEOUT_MS, signal });
394
+ const output = result.stdout.trim();
395
+ if (result.code !== 0 || !output) {
396
+ const error = result.stderr.trim() || `stage exited with code ${result.code}`;
397
+ transcript.push(`#### ${stage.label} Output\n\nFAILED: ${error}`);
398
+ return {
399
+ content: [
400
+ {
401
+ type: "text" as const,
402
+ text:
403
+ `${dirtyNote}${renderPipeline(pipeline, i, completedIndices)}` +
404
+ `${transcript.join("\n\n")}\n\n` +
405
+ `## Swarm Summary\n\nAborted at stage "${stage.label}": ${error}\n\n` +
406
+ `The worktree is kept for inspection: ${worktree.dir}\n` +
407
+ `Remove it with: git worktree remove --force ${worktree.dir}`,
408
+ },
409
+ ],
410
+ details: { pipeline: pipeline.name, task, failedStage: stage.name, worktree: worktree.dir },
411
+ };
412
+ }
413
+
414
+ stageOutputs.push(`#### ${stage.label} Output\n\n${output}`);
415
+ transcript.push(`#### ${stage.label} Output\n\n${output}`);
416
+ completedIndices.add(i);
417
+ }
418
+
419
+ // Collect and apply the swarm's changes.
420
+ const changes = await collectWorktreeChanges(exec, worktree.dir);
421
+ let applySection: string;
422
+ let keepWorktree = false;
423
+
424
+ if (!changes) {
425
+ applySection = `Could not collect changes from the worktree. It is kept for inspection: ${worktree.dir}`;
426
+ keepWorktree = true;
427
+ } else if (changes.files.length === 0) {
428
+ applySection = "The swarm made no file changes.";
429
+ } else {
430
+ const mainAfter = await changedFiles(exec, cwd);
431
+ const conflicts = findConflicts(changes.files, mainBefore, mainAfter);
432
+ if (conflicts.length > 0) {
433
+ applySection =
434
+ `NOT applied: ${conflicts.length} file(s) changed in your working tree while the swarm ran ` +
435
+ `(or were already dirty):\n${conflicts.map((f) => `- ${f}`).join("\n")}\n\n` +
436
+ `The worktree is kept so nothing is lost: ${worktree.dir}\n` +
437
+ `Inspect it and apply manually, then remove it with: git worktree remove --force ${worktree.dir}`;
438
+ keepWorktree = true;
439
+ } else {
440
+ const applied = await applyPatch(exec, cwd, changes.patch);
441
+ if (applied.ok) {
442
+ applySection =
443
+ `Applied ${changes.files.length} changed file(s) to your working tree (uncommitted, ready for review):\n` +
444
+ changes.files.map((f) => `- ${f}`).join("\n");
445
+ } else {
446
+ applySection =
447
+ `Patch failed to apply cleanly: ${applied.error}\n\n` +
448
+ `The worktree is kept so nothing is lost: ${worktree.dir}\n` +
449
+ `Remove it with: git worktree remove --force ${worktree.dir}`;
450
+ keepWorktree = true;
451
+ }
452
+ }
453
+ }
454
+
455
+ if (!keepWorktree) {
456
+ await removeWorktree(exec, cwd, worktree.dir);
457
+ }
458
+
459
+ return {
460
+ content: [
461
+ {
462
+ type: "text" as const,
463
+ text:
464
+ `${dirtyNote}${renderPipeline(pipeline, -1, completedIndices)}` +
465
+ `${transcript.join("\n\n")}\n\n` +
466
+ `## Swarm Summary\n\nAll ${pipeline.stages.length} stages completed in an isolated git worktree.\n\n${applySection}`,
467
+ },
468
+ ],
469
+ details: {
470
+ pipeline: pipeline.name,
471
+ stages: pipeline.stages.length,
472
+ task,
473
+ worktree: keepWorktree ? worktree.dir : undefined,
474
+ files: changes?.files ?? [],
475
+ },
476
+ };
477
+ } catch (err) {
478
+ // Unexpected failure (including abort): keep the worktree for recovery.
479
+ const message = err instanceof Error ? err.message : String(err);
480
+ return {
481
+ content: [
482
+ {
483
+ type: "text" as const,
484
+ text:
485
+ `Swarm worktree run failed: ${message}\n\n` +
486
+ `The worktree is kept for inspection: ${worktree.dir}\n` +
487
+ `Remove it with: git worktree remove --force ${worktree.dir}`,
488
+ },
489
+ ],
490
+ details: { pipeline: pipeline.name, task, worktree: worktree.dir },
491
+ };
492
+ }
493
+ }
494
+
495
+ // ── Extension ──────────────────────────────────────────────────────────────────────────────────────────
266
496
 
267
497
  export default function (elyra: ExtensionAPI): void {
268
498
  // -- Tool: swarm --
@@ -275,7 +505,10 @@ export default function (elyra: ExtensionAPI): void {
275
505
  "review (analyze -> correctness -> security -> tests -> synthesize), " +
276
506
  "refactor (analyze -> plan -> implement -> verify). " +
277
507
  "Each stage runs as a focused agent with specific instructions. " +
278
- "Results pass automatically between stages.",
508
+ "Results pass automatically between stages. " +
509
+ "Set worktrees=true to run every stage as an isolated sub-agent in a git worktree: " +
510
+ "write stages cannot touch your working tree, and the combined result is applied " +
511
+ "(uncommitted) only after a conflict check.",
279
512
  parameters: Type.Object({
280
513
  pipeline: Type.String({
281
514
  description: "Pipeline name: build, review, refactor",
@@ -283,8 +516,13 @@ export default function (elyra: ExtensionAPI): void {
283
516
  task: Type.String({
284
517
  description: "The task or target to work on",
285
518
  }),
519
+ worktrees: Type.Optional(
520
+ Type.Boolean({
521
+ description: "Run stages as isolated sub-agents in a git worktree (requires a git repo)",
522
+ }),
523
+ ),
286
524
  }),
287
- execute: async (_toolCallId, params) => {
525
+ execute: async (_toolCallId, params, signal, onUpdate, ctx) => {
288
526
  const pipeline = PIPELINES[params.pipeline];
289
527
  if (!pipeline) {
290
528
  const available = Object.entries(PIPELINES)
@@ -296,53 +534,21 @@ export default function (elyra: ExtensionAPI): void {
296
534
  };
297
535
  }
298
536
 
299
- const completedIndices = new Set<number>();
300
- const parts: string[] = [];
301
-
302
- // Render initial pipeline view
303
- parts.push(renderPipeline(pipeline, 0, completedIndices));
304
-
305
- // Generate instructions for all stages
306
- for (let i = 0; i < pipeline.stages.length; i++) {
307
- const stage = pipeline.stages[i]!;
308
- parts.push(renderStageHeader(pipeline, i, params.task));
309
-
310
- parts.push(`**Task**: ${params.task}\n\n`);
311
- parts.push(`**Instructions**:\n${stage.instructions}\n\n`);
312
-
313
- if (stage.readOnly) {
314
- parts.push(`**Constraint**: This is a read-only stage. Do NOT edit any files.\n\n`);
315
- }
316
-
317
- if (i < pipeline.stages.length - 1) {
318
- parts.push(`When done, write your output under a "#### ${stage.label} Output" heading, then proceed to the next stage.\n\n`);
319
- } else {
320
- parts.push(`This is the final stage. Write your output under a "#### ${stage.label} Output" heading, then provide a "## Swarm Summary" with the overall result.\n\n`);
321
- }
322
-
323
- completedIndices.add(i);
537
+ if (params.worktrees) {
538
+ return runWorktreePipeline(elyra, pipeline, params.task, ctx, signal, onUpdate);
324
539
  }
325
540
 
326
- // Final pipeline view (all complete)
327
- parts.push("---\n\n");
328
- parts.push(renderPipeline(pipeline, -1, completedIndices));
329
-
330
- return {
331
- content: [{ type: "text", text: parts.join("") }],
332
- details: {
333
- pipeline: pipeline.name,
334
- stages: pipeline.stages.length,
335
- task: params.task,
336
- },
337
- };
541
+ return buildInlineResult(pipeline, params.task);
338
542
  },
339
543
  });
340
544
 
341
545
  // -- Command: /swarm --
342
546
  elyra.registerCommand("swarm", {
343
- description: "Run a multi-agent swarm pipeline: /swarm <pipeline> <task>",
547
+ description: "Run a multi-agent swarm pipeline: /swarm [--worktrees] <pipeline> <task>",
344
548
  handler: async (args, ctx) => {
345
- const parts = args.trim().split(/\s+/);
549
+ const tokens = args.trim().split(/\s+/).filter((t) => t.length > 0);
550
+ const worktrees = tokens.includes("--worktrees");
551
+ const parts = tokens.filter((t) => t !== "--worktrees");
346
552
  const pipelineName = parts[0];
347
553
  const task = parts.slice(1).join(" ");
348
554
 
@@ -353,7 +559,9 @@ export default function (elyra: ExtensionAPI): void {
353
559
  return ` **${id}**: ${p.description}\n ${stages}`;
354
560
  })
355
561
  .join("\n\n");
356
- ctx.ui.notify(`Available swarm pipelines:\n\n${pipelineList}\n\nUsage: /swarm <pipeline> <task>`);
562
+ ctx.ui.notify(
563
+ `Available swarm pipelines:\n\n${pipelineList}\n\nUsage: /swarm [--worktrees] <pipeline> <task>\n\n--worktrees runs each stage as an isolated sub-agent in a git worktree.`,
564
+ );
357
565
  return;
358
566
  }
359
567
 
@@ -368,7 +576,8 @@ export default function (elyra: ExtensionAPI): void {
368
576
  return;
369
577
  }
370
578
 
371
- elyra.sendUserMessage(`Run the ${pipelineName} swarm pipeline: ${task}`);
579
+ const worktreeSuffix = worktrees ? " Use worktrees=true so every stage runs isolated in a git worktree." : "";
580
+ elyra.sendUserMessage(`Run the ${pipelineName} swarm pipeline: ${task}${worktreeSuffix}`);
372
581
  },
373
582
  });
374
583
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Git worktree helpers for swarm worktree mode.
3
+ *
4
+ * All functions take an injectable exec function so the logic stays testable
5
+ * and the extension can route through `elyra.exec`. Sub-agents work in a
6
+ * detached worktree based on HEAD; results come back as a single patch that
7
+ * is applied (uncommitted) to the main working tree after conflict checks.
8
+ */
9
+
10
+ import { mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ export interface ExecFn {
15
+ (command: string, args: string[], options?: { cwd?: string; timeout?: number; signal?: AbortSignal }): Promise<{
16
+ stdout: string;
17
+ stderr: string;
18
+ code: number;
19
+ }>;
20
+ }
21
+
22
+ const GIT_TIMEOUT = 30_000;
23
+
24
+ async function git(exec: ExecFn, cwd: string, args: string[]): Promise<{ stdout: string; code: number; stderr: string }> {
25
+ const result = await exec("git", args, { cwd, timeout: GIT_TIMEOUT });
26
+ return { stdout: result.stdout, code: result.code, stderr: result.stderr };
27
+ }
28
+
29
+ export async function isGitRepo(exec: ExecFn, cwd: string): Promise<boolean> {
30
+ try {
31
+ const result = await git(exec, cwd, ["rev-parse", "--is-inside-work-tree"]);
32
+ return result.code === 0 && result.stdout.trim() === "true";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Parse `git status --porcelain` output into a set of (current) relative paths. */
39
+ export function parsePorcelainPaths(porcelain: string): Set<string> {
40
+ const paths = new Set<string>();
41
+ for (const line of porcelain.split("\n")) {
42
+ if (line.length < 4) continue;
43
+ let p = line.slice(3);
44
+ const arrow = p.indexOf(" -> ");
45
+ if (arrow !== -1) p = p.slice(arrow + 4);
46
+ // Paths with special characters come back quoted.
47
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
48
+ if (p) paths.add(p);
49
+ }
50
+ return paths;
51
+ }
52
+
53
+ /** Relative paths with uncommitted changes (staged, unstaged, or untracked). */
54
+ export async function changedFiles(exec: ExecFn, cwd: string): Promise<Set<string>> {
55
+ const result = await git(exec, cwd, ["status", "--porcelain"]);
56
+ if (result.code !== 0) return new Set();
57
+ return parsePorcelainPaths(result.stdout);
58
+ }
59
+
60
+ export interface WorktreeInfo {
61
+ dir: string;
62
+ }
63
+
64
+ /** Create a detached worktree at HEAD under the system temp dir. */
65
+ export async function createWorktree(exec: ExecFn, cwd: string): Promise<WorktreeInfo | { error: string }> {
66
+ let dir: string;
67
+ try {
68
+ dir = mkdtempSync(join(tmpdir(), "elyra-swarm-"));
69
+ } catch (err) {
70
+ return { error: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ const result = await git(exec, cwd, ["worktree", "add", "--detach", dir, "HEAD"]);
73
+ if (result.code !== 0) {
74
+ return { error: result.stderr.trim() || `git worktree add exited with ${result.code}` };
75
+ }
76
+ return { dir };
77
+ }
78
+
79
+ export interface WorktreeResult {
80
+ /** Unified diff (with binary support) of everything the swarm changed. */
81
+ patch: string;
82
+ /** Relative paths touched by the swarm. */
83
+ files: string[];
84
+ }
85
+
86
+ /** Collect the swarm's changes from the worktree as a single patch. */
87
+ export async function collectWorktreeChanges(exec: ExecFn, worktreeDir: string): Promise<WorktreeResult | null> {
88
+ const addResult = await git(exec, worktreeDir, ["add", "-A"]);
89
+ if (addResult.code !== 0) return null;
90
+
91
+ const namesResult = await git(exec, worktreeDir, ["diff", "--cached", "--name-only"]);
92
+ if (namesResult.code !== 0) return null;
93
+ const files = namesResult.stdout
94
+ .split("\n")
95
+ .map((l) => l.trim())
96
+ .filter((l) => l.length > 0);
97
+ if (files.length === 0) return { patch: "", files: [] };
98
+
99
+ const diffResult = await git(exec, worktreeDir, ["diff", "--cached", "--binary"]);
100
+ if (diffResult.code !== 0) return null;
101
+ return { patch: diffResult.stdout, files };
102
+ }
103
+
104
+ /** Apply a patch to the main working tree (leaves changes uncommitted). */
105
+ export async function applyPatch(exec: ExecFn, cwd: string, patch: string): Promise<{ ok: boolean; error?: string }> {
106
+ let patchFile: string;
107
+ try {
108
+ const dir = mkdtempSync(join(tmpdir(), "elyra-swarm-patch-"));
109
+ patchFile = join(dir, "swarm.patch");
110
+ writeFileSync(patchFile, patch, "utf-8");
111
+ } catch (err) {
112
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
113
+ }
114
+ const result = await git(exec, cwd, ["apply", "--3way", patchFile]);
115
+ if (result.code !== 0) {
116
+ return { ok: false, error: result.stderr.trim() || `git apply exited with ${result.code}` };
117
+ }
118
+ return { ok: true };
119
+ }
120
+
121
+ export async function removeWorktree(exec: ExecFn, cwd: string, worktreeDir: string): Promise<void> {
122
+ try {
123
+ await git(exec, cwd, ["worktree", "remove", "--force", worktreeDir]);
124
+ await git(exec, cwd, ["worktree", "prune"]);
125
+ } catch {
126
+ // Best-effort cleanup; a leftover worktree is harmless and reported to the user.
127
+ }
128
+ }
129
+
130
+ /** Files both changed by the swarm and changed in the main tree while it ran. */
131
+ export function findConflicts(swarmFiles: string[], mainBefore: Set<string>, mainAfter: Set<string>): string[] {
132
+ const changedDuringRun = new Set<string>();
133
+ for (const f of mainAfter) {
134
+ if (!mainBefore.has(f)) changedDuringRun.add(f);
135
+ }
136
+ // Files already dirty before the run also conflict if the swarm touched them:
137
+ // the patch is based on HEAD, not on the dirty state.
138
+ for (const f of mainBefore) changedDuringRun.add(f);
139
+ return swarmFiles.filter((f) => changedDuringRun.has(f)).sort();
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyracode/swarm",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
4
4
  "description": "Multi-agent swarm orchestration for Elyra -- automated pipelines with visual progress tracking",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -43,4 +43,9 @@ Commands:
43
43
  /swarm build user registration with email verification
44
44
  /swarm review src/payments/
45
45
  /swarm refactor the notification system
46
+ /swarm --worktrees build rate limiting for the API
46
47
  ```
48
+
49
+ ## Worktree Mode
50
+
51
+ When the user asks for isolation, safety, or mentions worktrees, pass `worktrees=true` to the `swarm` tool. Each stage then runs as an isolated sub-agent in a detached git worktree: the user's working tree is untouched while the swarm runs, read-only stages are mechanically tool-restricted, and the combined result is applied uncommitted after a conflict check. Requires a git repository; degrades to inline mode otherwise.