@hueyexe/opencode-ensemble 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-477%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-482%20passing-brightgreen.svg)]()
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
7
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
@@ -15,7 +15,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
18
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
19
19
  }
20
20
  ```
21
21
 
@@ -136,7 +136,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
136
136
 
137
137
  ```json
138
138
  {
139
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
139
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
140
140
  }
141
141
  ```
142
142
 
@@ -144,7 +144,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
144
144
 
145
145
  ```json
146
146
  {
147
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
147
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
148
148
  }
149
149
  ```
150
150
 
@@ -201,7 +201,7 @@ Build with `bun run build`, then restart OpenCode to pick up changes.
201
201
  | `team_create` | Create a team. Caller becomes the lead. |
202
202
  | `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
203
203
  | `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
204
- | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). |
204
+ | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. |
205
205
  | `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. |
206
206
  | `team_status` | See all members, their status, and a task summary. |
207
207
  | `team_view` | Switch the TUI to a teammate's session. |
@@ -252,6 +252,7 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
252
252
  - **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
253
253
  - **Peer-to-peer communication**: teammates can message each other directly, with idle-flush delivery and chatty agent detection
254
254
  - **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
255
+ - **Overlap detection**: `team_merge` blocks when you have local changes to files the agent also modified, preventing silent overwrites
255
256
  - **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
256
257
  - **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
257
258
  - **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
@@ -405,7 +406,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
405
406
  ```bash
406
407
  bun install
407
408
  bun run typecheck
408
- bun test # 477 tests
409
+ bun test # 482 tests
409
410
  bun run build
410
411
  ```
411
412
 
package/dist/index.js CHANGED
@@ -33,9 +33,8 @@ __export(exports_merge_helper, {
33
33
  preserveBranch: () => preserveBranch,
34
34
  mergeBranchRaw: () => mergeBranchRaw,
35
35
  mergeBranch: () => mergeBranch,
36
- gitStashPop: () => gitStashPop,
37
- gitStash: () => gitStash,
38
36
  gitReset: () => gitReset,
37
+ getOverlappingFiles: () => getOverlappingFiles,
39
38
  deleteBranch: () => deleteBranch
40
39
  });
41
40
  async function preserveBranch(sourceBranch, targetBranch, cwd) {
@@ -70,48 +69,33 @@ async function mergeBranchRaw(branch, cwd) {
70
69
  }
71
70
  return { ok: true };
72
71
  }
73
- async function gitStash(cwd) {
74
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
75
- const stashOut = await new Response(stash.stdout).text();
76
- const stashExit = await stash.exited;
77
- return stashExit === 0 && !stashOut.includes("No local changes");
78
- }
79
72
  async function gitReset(cwd) {
80
73
  const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
81
74
  await reset.exited;
82
75
  }
83
- async function gitStashPop(cwd) {
84
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
85
- const exit = await pop.exited;
86
- if (exit !== 0) {
87
- const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" });
88
- const fallbackExit = await fallback.exited;
89
- if (fallbackExit !== 0) {
90
- log("merge-helper:stash-pop:failed \u2014 stashed work remains in git stash list");
91
- return false;
92
- }
93
- }
94
- return true;
95
- }
96
76
  async function mergeBranch(branch, cwd) {
97
- const didStash = await gitStash(cwd);
98
77
  const result = await mergeBranchRaw(branch, cwd);
99
- if (!result.ok) {
100
- if (didStash) {
101
- const restored = await gitStashPop(cwd);
102
- if (!restored)
103
- return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored \u2014 check git stash list` };
104
- }
78
+ if (!result.ok)
105
79
  return result;
106
- }
107
80
  await gitReset(cwd);
108
- if (didStash) {
109
- const restored = await gitStashPop(cwd);
110
- if (!restored)
111
- return { ok: true, error: "Merge succeeded but stashed work could not be restored \u2014 check git stash list" };
112
- }
113
81
  return { ok: true };
114
82
  }
83
+ async function getOverlappingFiles(branch, cwd) {
84
+ const run = async (args) => {
85
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
86
+ const out = await new Response(proc.stdout).text();
87
+ const exit = await proc.exited;
88
+ if (exit !== 0)
89
+ throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`);
90
+ return out.split(`
91
+ `).filter(Boolean);
92
+ };
93
+ const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]));
94
+ const localChanged = await run(["diff", "--name-only", "HEAD"]);
95
+ const localUntracked = await run(["ls-files", "--others", "--exclude-standard"]);
96
+ const localFiles = [...new Set([...localChanged, ...localUntracked])];
97
+ return localFiles.filter((f) => agentFiles.has(f));
98
+ }
115
99
  function preservedBranchName(teamName, memberName) {
116
100
  return `ensemble/preserved/${teamName}/${memberName}`;
117
101
  }
@@ -15988,7 +15972,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
15988
15972
  }
15989
15973
  lines.push(`If user picks "I'll choose per agent", ask for each agent's model individually before each team_spawn call.`, "Pass the chosen model via the model parameter on each team_spawn call.");
15990
15974
  }
15991
- lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which handles stashing and unstaging.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15975
+ lines.push("", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which squash-merges and unstages for you.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15992
15976
  return lines.join(`
15993
15977
  `);
15994
15978
  }
@@ -16860,6 +16844,19 @@ async function executeTeamCreate(deps, args, sessionId) {
16860
16844
  }
16861
16845
 
16862
16846
  // src/tools/shared.ts
16847
+ async function countBranchCommits(branch, cwd) {
16848
+ try {
16849
+ const proc = Bun.spawn(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd, stdout: "pipe", stderr: "pipe" });
16850
+ const out = await new Response(proc.stdout).text();
16851
+ const exit = await proc.exited;
16852
+ if (exit !== 0)
16853
+ return -1;
16854
+ const n = Number.parseInt(out.trim(), 10);
16855
+ return Number.isNaN(n) ? -1 : n;
16856
+ } catch {
16857
+ return -1;
16858
+ }
16859
+ }
16863
16860
  async function checkWorktreeDirty(dir) {
16864
16861
  try {
16865
16862
  const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
@@ -17243,7 +17240,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17243
17240
 
17244
17241
  // src/tools/team-shutdown.ts
17245
17242
  init_merge_helper();
17246
- async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch) {
17243
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch, commitCount = countBranchCommits) {
17247
17244
  const teamInfo = requireLead(deps, sessionId);
17248
17245
  const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
17249
17246
  if (!member)
@@ -17253,9 +17250,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17253
17250
  const force = args.force ?? false;
17254
17251
  if (member.status === "shutdown_requested") {
17255
17252
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
17256
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
17257
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
17258
- return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
17253
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount);
17254
+ return `Force shut down "${args.member}".${status}`;
17259
17255
  }
17260
17256
  let isIdle = false;
17261
17257
  try {
@@ -17265,9 +17261,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17265
17261
  } catch {}
17266
17262
  if (isIdle || force) {
17267
17263
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
17268
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
17269
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
17270
- return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
17264
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount);
17265
+ return `Teammate "${args.member}" has been shut down.${status}`;
17271
17266
  }
17272
17267
  if (member.worktree_branch) {
17273
17268
  const safeBranch = preservedBranchName(teamInfo.teamName, args.member);
@@ -17305,26 +17300,35 @@ async function preserveAndAbort(deps, teamId, teamName, memberName, sessionId, w
17305
17300
  } catch {}
17306
17301
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
17307
17302
  }
17308
- function getBranchInfo(deps, teamId, memberName) {
17303
+ async function getBranchStatus(deps, teamId, memberName, worktreeDir, isDirty, commitCount) {
17309
17304
  const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
17310
- return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : "";
17311
- }
17312
- async function getDirtyWarning(worktreeDir, memberName, isDirty) {
17313
- if (!worktreeDir)
17305
+ if (!row?.worktree_branch)
17314
17306
  return "";
17315
- try {
17316
- if (await isDirty(worktreeDir)) {
17317
- return `
17318
-
17319
- Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
17320
- }
17321
- } catch {}
17322
- return "";
17307
+ const branch = row.worktree_branch;
17308
+ const parts = [];
17309
+ const commits = await commitCount(branch, deps.directory);
17310
+ const dirty = worktreeDir ? await isDirty(worktreeDir).catch(() => false) : false;
17311
+ if (commits > 0 && dirty) {
17312
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""} and has uncommitted work.`);
17313
+ } else if (commits > 0) {
17314
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""}. Ready to merge.`);
17315
+ } else if (dirty) {
17316
+ parts.push(`${memberName} has uncommitted changes only \u2014 their work may be incomplete.`);
17317
+ } else if (commits < 0) {
17318
+ parts.push(`Could not determine ${memberName}'s commit status. Merge to check their work.`);
17319
+ } else {
17320
+ parts.push(`${memberName} made no changes.`);
17321
+ }
17322
+ parts.push(`Branch: ${branch}`);
17323
+ parts.push("Use team_merge to merge their work.");
17324
+ return `
17325
+ ${parts.join(`
17326
+ `)}`;
17323
17327
  }
17324
17328
 
17325
17329
  // src/tools/team-cleanup.ts
17326
17330
  init_merge_helper();
17327
- async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true) {
17331
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true, overlapCheck = getOverlappingFiles) {
17328
17332
  const teamInfo = requireLead(deps, sessionId);
17329
17333
  const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
17330
17334
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -17372,9 +17376,16 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17372
17376
  const unmerged = members.filter((m) => m.worktree_branch !== null);
17373
17377
  const merged = [];
17374
17378
  const conflicted = [];
17379
+ const overlapWarnings = [];
17375
17380
  if (unmerged.length > 0 && mergeOnCleanup) {
17376
17381
  for (const member of unmerged) {
17377
17382
  const branch = member.worktree_branch;
17383
+ try {
17384
+ const overlap = await overlapCheck(branch, deps.directory);
17385
+ if (overlap.length > 0) {
17386
+ overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`);
17387
+ }
17388
+ } catch {}
17378
17389
  const result = await merge2(branch, deps.directory);
17379
17390
  if (result.ok) {
17380
17391
  await delBranch(branch, deps.directory);
@@ -17410,13 +17421,19 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17410
17421
  if (conflicted.length > 0) {
17411
17422
  parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`);
17412
17423
  }
17424
+ if (overlapWarnings.length > 0) {
17425
+ parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:
17426
+ ${overlapWarnings.map((w) => ` - ${w}`).join(`
17427
+ `)}
17428
+ Review with: git diff`);
17429
+ }
17413
17430
  return parts.join(`
17414
17431
  `);
17415
17432
  }
17416
17433
 
17417
17434
  // src/tools/team-merge.ts
17418
17435
  init_merge_helper();
17419
- async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch) {
17436
+ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles) {
17420
17437
  const teamInfo = requireLead(deps, sessionId);
17421
17438
  const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
17422
17439
  if (!member)
@@ -17429,17 +17446,39 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
17429
17446
  }
17430
17447
  const branch = member.worktree_branch;
17431
17448
  log(`merge:start member=${args.member} branch=${branch}`);
17449
+ try {
17450
+ const overlap = await overlapCheck(branch, deps.directory);
17451
+ if (overlap.length > 0) {
17452
+ const files = overlap.map((f) => ` - ${f}`).join(`
17453
+ `);
17454
+ return [
17455
+ `Cannot merge ${args.member} \u2014 you have local changes to the same files:`,
17456
+ files,
17457
+ ``,
17458
+ `Commit or stash your changes first, then retry team_merge.`,
17459
+ `Branch preserved: ${branch}`
17460
+ ].join(`
17461
+ `);
17462
+ }
17463
+ } catch {
17464
+ log(`merge:overlap-check:failed member=${args.member} branch=${branch}`);
17465
+ }
17432
17466
  const result = await merge2(branch, deps.directory);
17433
17467
  if (!result.ok) {
17434
- return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:
17435
- git merge --squash ${branch}
17436
-
17437
- Error: ${result.error}`;
17468
+ return [
17469
+ `Merge conflict merging ${args.member}'s branch (${branch}).`,
17470
+ `Resolve manually:`,
17471
+ ` git merge --squash ${branch}`,
17472
+ ` git reset HEAD`,
17473
+ ``,
17474
+ `Error: ${result.error}`
17475
+ ].join(`
17476
+ `);
17438
17477
  }
17439
17478
  await delBranch(branch, deps.directory);
17440
17479
  deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
17441
17480
  log(`merge:done member=${args.member} branch=${branch}`);
17442
- return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
17481
+ return `Merged ${args.member}'s changes into your working directory (unstaged). Review with: git diff`;
17443
17482
  }
17444
17483
 
17445
17484
  // src/tools/team-tasks-list.ts
@@ -17502,6 +17541,20 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
17502
17541
  unblocked++;
17503
17542
  }
17504
17543
  }
17544
+ const counts = deps.db.query("SELECT status, COUNT(*) as c FROM team_task WHERE team_id = ? GROUP BY status").all(teamInfo.teamId);
17545
+ const completed = counts.find((r) => r.status === "completed")?.c ?? 0;
17546
+ const total = counts.reduce((sum, r) => sum + r.c, 0);
17547
+ const who = teamInfo.memberName ?? "teammate";
17548
+ try {
17549
+ deps.client.tui.showToast({
17550
+ title: "Team",
17551
+ message: `${who}: ${completed}/${total} tasks complete`,
17552
+ variant: "info",
17553
+ duration: 3000
17554
+ }).catch(() => {});
17555
+ } catch {
17556
+ log(`tasks-complete:toast:failed`);
17557
+ }
17505
17558
  const unblockedMsg = unblocked > 0 ? ` Unblocked ${unblocked} dependent task${unblocked !== 1 ? "s" : ""}.` : "";
17506
17559
  return `Completed task: ${task.content}${unblockedMsg}`;
17507
17560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -133,9 +133,8 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
133
133
 
134
134
  lines.push(
135
135
  "",
136
- "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.",
137
- "Wait for the tool result before spawning the next teammate.",
138
- "Multiple team_spawn calls in a single response will cause timeouts.",
136
+ "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.",
137
+ "This avoids git worktree contention. Once all are spawned, wait for their messages.",
139
138
  "",
140
139
  "Teammates work asynchronously and message you when done.",
141
140
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
@@ -145,7 +144,7 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
145
144
  "MERGE WORKFLOW:",
146
145
  "After a teammate finishes and you shut them down, use team_merge to merge their branch.",
147
146
  "Do NOT tell teammates to commit — they handle that themselves.",
148
- "Do NOT run git merge manually — use team_merge which handles stashing and unstaging.",
147
+ "Do NOT run git merge manually — use team_merge which squash-merges and unstages for you.",
149
148
  "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.",
150
149
  "",
151
150
  "Before calling team_cleanup, verify teammates have committed their work.",
@@ -9,6 +9,9 @@ export interface MergeResult {
9
9
  /** Injectable function for testing. */
10
10
  export type MergeBranchFn = (branch: string, cwd: string) => Promise<MergeResult>
11
11
 
12
+ /** Injectable function for overlap detection before merge. */
13
+ export type OverlapCheckFn = (branch: string, cwd: string) => Promise<string[]>
14
+
12
15
  /** Injectable function for preserving a branch before worktree deletion. */
13
16
  export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd: string) => Promise<boolean>
14
17
 
@@ -64,60 +67,43 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
64
67
  return { ok: true }
65
68
  }
66
69
 
67
- /** Stash existing work. Returns true if something was stashed. */
68
- export async function gitStash(cwd: string): Promise<boolean> {
69
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
70
- const stashOut = await new Response(stash.stdout).text()
71
- const stashExit = await stash.exited
72
- return stashExit === 0 && !stashOut.includes("No local changes")
73
- }
74
-
75
- /** Unstage all changes. */
70
+ /** Unstage all changes so merge results appear as unstaged. */
76
71
  export async function gitReset(cwd: string): Promise<void> {
77
72
  const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
78
73
  await reset.exited
79
74
  }
80
75
 
81
- /** Restore stashed work, preserving staged/unstaged state. Returns true if successful. */
82
- export async function gitStashPop(cwd: string): Promise<boolean> {
83
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
84
- const exit = await pop.exited
85
- if (exit !== 0) {
86
- // --index is strict about staged/unstaged distinction — fall back to plain pop
87
- const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" })
88
- const fallbackExit = await fallback.exited
89
- if (fallbackExit !== 0) {
90
- log("merge-helper:stash-pop:failed — stashed work remains in git stash list")
91
- return false
92
- }
93
- }
94
- return true
95
- }
96
-
97
76
  /**
98
- * Stash-safe squash merge of a single branch into the working directory.
99
- * Stashes existing work, merges, unstages, restores stash.
77
+ * Squash merge a branch into the working directory as unstaged changes.
78
+ * No stashing — existing unstaged changes from previous merges are preserved.
79
+ * If the merge conflicts, the lead resolves it with git.
100
80
  */
101
81
  export async function mergeBranch(branch: string, cwd: string): Promise<MergeResult> {
102
- const didStash = await gitStash(cwd)
103
-
104
82
  const result = await mergeBranchRaw(branch, cwd)
105
- if (!result.ok) {
106
- if (didStash) {
107
- const restored = await gitStashPop(cwd)
108
- if (!restored) return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored — check git stash list` }
109
- }
110
- return result
111
- }
112
-
83
+ if (!result.ok) return result
113
84
  await gitReset(cwd)
114
- if (didStash) {
115
- const restored = await gitStashPop(cwd)
116
- if (!restored) return { ok: true, error: "Merge succeeded but stashed work could not be restored — check git stash list" }
117
- }
118
85
  return { ok: true }
119
86
  }
120
87
 
88
+ /**
89
+ * Detect files that both the lead (local changes) and the agent (branch) modified.
90
+ * Returns the list of overlapping file paths, or empty if safe to merge.
91
+ */
92
+ export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
93
+ const run = async (args: string[]) => {
94
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
95
+ const out = await new Response(proc.stdout).text()
96
+ const exit = await proc.exited
97
+ if (exit !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`)
98
+ return out.split("\n").filter(Boolean)
99
+ }
100
+ const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
101
+ const localChanged = await run(["diff", "--name-only", "HEAD"])
102
+ const localUntracked = await run(["ls-files", "--others", "--exclude-standard"])
103
+ const localFiles = [...new Set([...localChanged, ...localUntracked])]
104
+ return localFiles.filter(f => agentFiles.has(f))
105
+ }
106
+
121
107
  /**
122
108
  * Build the preserved branch name for a team member.
123
109
  */
@@ -4,6 +4,21 @@ import { findTeamBySession } from "../types"
4
4
  /** Function type for dirty worktree check — injectable for testing. */
5
5
  export type IsDirtyFn = (dir: string) => Promise<boolean>
6
6
 
7
+ /** Function type for counting commits on a branch — injectable for testing. */
8
+ export type CommitCountFn = (branch: string, cwd: string) => Promise<number>
9
+
10
+ /** Count commits a branch has ahead of HEAD. Approximate — may include base divergence. Returns -1 if check fails. */
11
+ export async function countBranchCommits(branch: string, cwd: string): Promise<number> {
12
+ try {
13
+ const proc = Bun.spawn(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd, stdout: "pipe", stderr: "pipe" })
14
+ const out = await new Response(proc.stdout).text()
15
+ const exit = await proc.exited
16
+ if (exit !== 0) return -1
17
+ const n = Number.parseInt(out.trim(), 10)
18
+ return Number.isNaN(n) ? -1 : n
19
+ } catch { return -1 }
20
+ }
21
+
7
22
  /** Check if a worktree directory has uncommitted changes via git status. */
8
23
  export async function checkWorktreeDirty(dir: string): Promise<boolean> {
9
24
  try {
@@ -2,8 +2,8 @@ import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
4
  import { spawnFailures } from "./team-spawn"
5
- import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName } from "./merge-helper"
6
- import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn } from "./merge-helper"
5
+ import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
6
+ import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
7
7
  import { log } from "../log"
8
8
 
9
9
  /**
@@ -19,6 +19,7 @@ export async function executeTeamCleanup(
19
19
  merge: MergeBranchFn = mergeBranch,
20
20
  delBranch: DeleteBranchFn = deleteBranch,
21
21
  mergeOnCleanup = true,
22
+ overlapCheck: OverlapCheckFn = getOverlappingFiles,
22
23
  ): Promise<string> {
23
24
  const teamInfo = requireLead(deps, sessionId)
24
25
 
@@ -75,10 +76,18 @@ export async function executeTeamCleanup(
75
76
  const unmerged = members.filter(m => m.worktree_branch !== null)
76
77
  const merged: string[] = []
77
78
  const conflicted: string[] = []
79
+ const overlapWarnings: string[] = []
78
80
 
79
81
  if (unmerged.length > 0 && mergeOnCleanup) {
80
82
  for (const member of unmerged) {
81
83
  const branch = member.worktree_branch!
84
+ // Warn (but don't block) if lead has local changes to overlapping files
85
+ try {
86
+ const overlap = await overlapCheck(branch, deps.directory)
87
+ if (overlap.length > 0) {
88
+ overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`)
89
+ }
90
+ } catch { /* best effort */ }
82
91
  const result = await merge(branch, deps.directory)
83
92
  if (result.ok) {
84
93
  await delBranch(branch, deps.directory)
@@ -122,5 +131,8 @@ export async function executeTeamCleanup(
122
131
  if (conflicted.length > 0) {
123
132
  parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`)
124
133
  }
134
+ if (overlapWarnings.length > 0) {
135
+ parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:\n${overlapWarnings.map(w => ` - ${w}`).join("\n")}\nReview with: git diff`)
136
+ }
125
137
  return parts.join("\n")
126
138
  }
@@ -1,7 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireLead } from "./shared"
3
- import { mergeBranch, deleteBranch } from "./merge-helper"
4
- import type { MergeBranchFn, DeleteBranchFn } from "./merge-helper"
3
+ import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
4
+ import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
5
5
  import { log } from "../log"
6
6
 
7
7
  /**
@@ -14,6 +14,7 @@ export async function executeTeamMerge(
14
14
  sessionId: string,
15
15
  merge: MergeBranchFn = mergeBranch,
16
16
  delBranch: DeleteBranchFn = deleteBranch,
17
+ overlapCheck: OverlapCheckFn = getOverlappingFiles,
17
18
  ): Promise<string> {
18
19
  const teamInfo = requireLead(deps, sessionId)
19
20
 
@@ -32,9 +33,33 @@ export async function executeTeamMerge(
32
33
  const branch = member.worktree_branch
33
34
  log(`merge:start member=${args.member} branch=${branch}`)
34
35
 
36
+ // Block merge if lead has local changes to files the agent also modified
37
+ try {
38
+ const overlap = await overlapCheck(branch, deps.directory)
39
+ if (overlap.length > 0) {
40
+ const files = overlap.map(f => ` - ${f}`).join("\n")
41
+ return [
42
+ `Cannot merge ${args.member} — you have local changes to the same files:`,
43
+ files,
44
+ ``,
45
+ `Commit or stash your changes first, then retry team_merge.`,
46
+ `Branch preserved: ${branch}`,
47
+ ].join("\n")
48
+ }
49
+ } catch {
50
+ log(`merge:overlap-check:failed member=${args.member} branch=${branch}`)
51
+ }
52
+
35
53
  const result = await merge(branch, deps.directory)
36
54
  if (!result.ok) {
37
- return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:\n git merge --squash ${branch}\n\nError: ${result.error}`
55
+ return [
56
+ `Merge conflict merging ${args.member}'s branch (${branch}).`,
57
+ `Resolve manually:`,
58
+ ` git merge --squash ${branch}`,
59
+ ` git reset HEAD`,
60
+ ``,
61
+ `Error: ${result.error}`,
62
+ ].join("\n")
38
63
  }
39
64
 
40
65
  // Merge succeeded — delete the preserved branch and clear DB
@@ -45,5 +70,5 @@ export async function executeTeamMerge(
45
70
  )
46
71
 
47
72
  log(`merge:done member=${args.member} branch=${branch}`)
48
- return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
73
+ return `Merged ${args.member}'s changes into your working directory (unstaged). Review with: git diff`
49
74
  }
@@ -1,6 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { requireLead, checkWorktreeDirty } from "./shared"
3
- import type { IsDirtyFn } from "./shared"
2
+ import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
3
+ import type { IsDirtyFn, CommitCountFn } from "./shared"
4
4
  import { preserveBranch, preservedBranchName } from "./merge-helper"
5
5
  import type { PreserveBranchFn } from "./merge-helper"
6
6
  import { log } from "../log"
@@ -17,6 +17,7 @@ export async function executeTeamShutdown(
17
17
  sessionId: string,
18
18
  isDirty: IsDirtyFn = checkWorktreeDirty,
19
19
  preserve: PreserveBranchFn = preserveBranch,
20
+ commitCount: CommitCountFn = countBranchCommits,
20
21
  ): Promise<string> {
21
22
  const teamInfo = requireLead(deps, sessionId)
22
23
 
@@ -30,9 +31,8 @@ export async function executeTeamShutdown(
30
31
  // Second call on an already-requested member → force abort
31
32
  if (member.status === "shutdown_requested") {
32
33
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
33
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
34
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
35
- return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
34
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
35
+ return `Force shut down "${args.member}".${status}`
36
36
  }
37
37
 
38
38
  // Determine if member is idle or busy
@@ -47,9 +47,8 @@ export async function executeTeamShutdown(
47
47
 
48
48
  if (isIdle || force) {
49
49
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve)
50
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member)
51
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
52
- return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
50
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
51
+ return `Teammate "${args.member}" has been shut down.${status}`
53
52
  }
54
53
 
55
54
  // Busy + not force → graceful: preserve branch first, then send shutdown message
@@ -129,20 +128,39 @@ async function preserveAndAbort(
129
128
  )
130
129
  }
131
130
 
132
- /** Read the current branch from DB (may have been updated to preserved name). */
133
- function getBranchInfo(deps: ToolDeps, teamId: string, memberName: string): string {
131
+ /** Build a status line describing the teammate's work: commit count, dirty state, next step. */
132
+ async function getBranchStatus(
133
+ deps: ToolDeps,
134
+ teamId: string,
135
+ memberName: string,
136
+ worktreeDir: string | null,
137
+ isDirty: IsDirtyFn,
138
+ commitCount: CommitCountFn,
139
+ ): Promise<string> {
134
140
  const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
135
141
  .get(teamId, memberName) as { worktree_branch: string | null } | null
136
- return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : ""
137
- }
142
+ if (!row?.worktree_branch) return ""
138
143
 
139
- /** Build a warning suffix if the member's worktree has uncommitted changes. */
140
- async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
141
- if (!worktreeDir) return ""
142
- try {
143
- if (await isDirty(worktreeDir)) {
144
- return `\n\nWarning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`
145
- }
146
- } catch { /* best effort */ }
147
- return ""
144
+ const branch = row.worktree_branch
145
+ const parts: string[] = []
146
+
147
+ const commits = await commitCount(branch, deps.directory)
148
+ // Best-effort dirty check — worktree may already be deleted by session.abort() race
149
+ const dirty = worktreeDir ? await isDirty(worktreeDir).catch(() => false) : false
150
+
151
+ if (commits > 0 && dirty) {
152
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""} and has uncommitted work.`)
153
+ } else if (commits > 0) {
154
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""}. Ready to merge.`)
155
+ } else if (dirty) {
156
+ parts.push(`${memberName} has uncommitted changes only — their work may be incomplete.`)
157
+ } else if (commits < 0) {
158
+ parts.push(`Could not determine ${memberName}'s commit status. Merge to check their work.`)
159
+ } else {
160
+ parts.push(`${memberName} made no changes.`)
161
+ }
162
+
163
+ parts.push(`Branch: ${branch}`)
164
+ parts.push("Use team_merge to merge their work.")
165
+ return `\n${parts.join("\n")}`
148
166
  }
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
+ import { log } from "../log"
3
4
 
4
5
  /**
5
6
  * Execute the team_tasks_complete tool. Marks a task as completed
@@ -29,9 +30,8 @@ export async function executeTeamTasksComplete(
29
30
  const depIds: string[] = JSON.parse(t.depends_on)
30
31
  if (!depIds.includes(args.task_id)) continue
31
32
 
32
- // Check if all dependencies are now resolved
33
33
  const allResolved = depIds.every(depId => {
34
- if (depId === args.task_id) return true // just completed
34
+ if (depId === args.task_id) return true
35
35
  const dep = allTasks.find(d => d.id === depId)
36
36
  return dep && (dep.status === "completed" || dep.status === "cancelled")
37
37
  })
@@ -42,6 +42,22 @@ export async function executeTeamTasksComplete(
42
42
  }
43
43
  }
44
44
 
45
+ // Fire progress toast so the lead has visibility
46
+ const counts = deps.db.query(
47
+ "SELECT status, COUNT(*) as c FROM team_task WHERE team_id = ? GROUP BY status"
48
+ ).all(teamInfo.teamId) as Array<{ status: string; c: number }>
49
+ const completed = counts.find(r => r.status === "completed")?.c ?? 0
50
+ const total = counts.reduce((sum, r) => sum + r.c, 0)
51
+ const who = teamInfo.memberName ?? "teammate"
52
+ try {
53
+ deps.client.tui.showToast({
54
+ title: "Team",
55
+ message: `${who}: ${completed}/${total} tasks complete`,
56
+ variant: "info",
57
+ duration: 3000,
58
+ }).catch(() => { /* TUI may not be available */ })
59
+ } catch { log(`tasks-complete:toast:failed`) }
60
+
45
61
  const unblockedMsg = unblocked > 0 ? ` Unblocked ${unblocked} dependent task${unblocked !== 1 ? "s" : ""}.` : ""
46
62
  return `Completed task: ${task.content}${unblockedMsg}`
47
63
  }