@hueyexe/opencode-ensemble 0.12.1 → 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
@@ -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.1"]
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.1"]
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.1"]
147
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.0"]
148
148
  }
149
149
  ```
150
150
 
package/dist/index.js CHANGED
@@ -33,8 +33,6 @@ __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,
39
37
  getOverlappingFiles: () => getOverlappingFiles,
40
38
  deleteBranch: () => deleteBranch
@@ -71,46 +69,15 @@ async function mergeBranchRaw(branch, cwd) {
71
69
  }
72
70
  return { ok: true };
73
71
  }
74
- async function gitStash(cwd) {
75
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
76
- const stashOut = await new Response(stash.stdout).text();
77
- const stashExit = await stash.exited;
78
- return stashExit === 0 && !stashOut.includes("No local changes");
79
- }
80
72
  async function gitReset(cwd) {
81
73
  const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
82
74
  await reset.exited;
83
75
  }
84
- async function gitStashPop(cwd) {
85
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
86
- const exit = await pop.exited;
87
- if (exit !== 0) {
88
- const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" });
89
- const fallbackExit = await fallback.exited;
90
- if (fallbackExit !== 0) {
91
- log("merge-helper:stash-pop:failed \u2014 stashed work remains in git stash list");
92
- return false;
93
- }
94
- }
95
- return true;
96
- }
97
76
  async function mergeBranch(branch, cwd) {
98
- const didStash = await gitStash(cwd);
99
77
  const result = await mergeBranchRaw(branch, cwd);
100
- if (!result.ok) {
101
- if (didStash) {
102
- const restored = await gitStashPop(cwd);
103
- if (!restored)
104
- return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored \u2014 check git stash list` };
105
- }
78
+ if (!result.ok)
106
79
  return result;
107
- }
108
80
  await gitReset(cwd);
109
- if (didStash) {
110
- const restored = await gitStashPop(cwd);
111
- if (!restored)
112
- return { ok: true, error: "Merge succeeded but stashed work could not be restored \u2014 check git stash list" };
113
- }
114
81
  return { ok: true };
115
82
  }
116
83
  async function getOverlappingFiles(branch, cwd) {
@@ -16005,7 +15972,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
16005
15972
  }
16006
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.");
16007
15974
  }
16008
- 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.");
16009
15976
  return lines.join(`
16010
15977
  `);
16011
15978
  }
@@ -16877,6 +16844,19 @@ async function executeTeamCreate(deps, args, sessionId) {
16877
16844
  }
16878
16845
 
16879
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
+ }
16880
16860
  async function checkWorktreeDirty(dir) {
16881
16861
  try {
16882
16862
  const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
@@ -17260,7 +17240,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17260
17240
 
17261
17241
  // src/tools/team-shutdown.ts
17262
17242
  init_merge_helper();
17263
- async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch) {
17243
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch, commitCount = countBranchCommits) {
17264
17244
  const teamInfo = requireLead(deps, sessionId);
17265
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);
17266
17246
  if (!member)
@@ -17270,9 +17250,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17270
17250
  const force = args.force ?? false;
17271
17251
  if (member.status === "shutdown_requested") {
17272
17252
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
17273
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
17274
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
17275
- 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}`;
17276
17255
  }
17277
17256
  let isIdle = false;
17278
17257
  try {
@@ -17282,9 +17261,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17282
17261
  } catch {}
17283
17262
  if (isIdle || force) {
17284
17263
  await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
17285
- const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
17286
- const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
17287
- 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}`;
17288
17266
  }
17289
17267
  if (member.worktree_branch) {
17290
17268
  const safeBranch = preservedBranchName(teamInfo.teamName, args.member);
@@ -17322,21 +17300,30 @@ async function preserveAndAbort(deps, teamId, teamName, memberName, sessionId, w
17322
17300
  } catch {}
17323
17301
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
17324
17302
  }
17325
- function getBranchInfo(deps, teamId, memberName) {
17303
+ async function getBranchStatus(deps, teamId, memberName, worktreeDir, isDirty, commitCount) {
17326
17304
  const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
17327
- return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : "";
17328
- }
17329
- async function getDirtyWarning(worktreeDir, memberName, isDirty) {
17330
- if (!worktreeDir)
17305
+ if (!row?.worktree_branch)
17331
17306
  return "";
17332
- try {
17333
- if (await isDirty(worktreeDir)) {
17334
- return `
17335
-
17336
- Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
17337
- }
17338
- } catch {}
17339
- 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
+ `)}`;
17340
17327
  }
17341
17328
 
17342
17329
  // src/tools/team-cleanup.ts
@@ -17438,7 +17425,7 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17438
17425
  parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:
17439
17426
  ${overlapWarnings.map((w) => ` - ${w}`).join(`
17440
17427
  `)}
17441
- Check git stash list or git diff to recover.`);
17428
+ Review with: git diff`);
17442
17429
  }
17443
17430
  return parts.join(`
17444
17431
  `);
@@ -17464,29 +17451,34 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
17464
17451
  if (overlap.length > 0) {
17465
17452
  const files = overlap.map((f) => ` - ${f}`).join(`
17466
17453
  `);
17467
- return `Cannot merge ${args.member}'s branch \u2014 you have local changes to files that ${args.member} also modified:
17468
- ${files}
17469
-
17470
- Commit or stash your changes to these files first, then retry team_merge.
17471
- Branch preserved: ${branch}`;
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
+ `);
17472
17462
  }
17473
17463
  } catch {
17474
17464
  log(`merge:overlap-check:failed member=${args.member} branch=${branch}`);
17475
17465
  }
17476
17466
  const result = await merge2(branch, deps.directory);
17477
17467
  if (!result.ok) {
17478
- return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:
17479
- git merge --squash ${branch}
17480
-
17481
- 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
+ `);
17482
17477
  }
17483
17478
  await delBranch(branch, deps.directory);
17484
17479
  deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
17485
17480
  log(`merge:done member=${args.member} branch=${branch}`);
17486
- const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
17487
- return result.error ? `${msg}
17488
-
17489
- Warning: ${result.error}` : msg;
17481
+ return `Merged ${args.member}'s changes into your working directory (unstaged). Review with: git diff`;
17490
17482
  }
17491
17483
 
17492
17484
  // src/tools/team-tasks-list.ts
@@ -17549,6 +17541,20 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
17549
17541
  unblocked++;
17550
17542
  }
17551
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
+ }
17552
17558
  const unblockedMsg = unblocked > 0 ? ` Unblocked ${unblocked} dependent task${unblocked !== 1 ? "s" : ""}.` : "";
17553
17559
  return `Completed task: ${task.content}${unblockedMsg}`;
17554
17560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.12.1",
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.",
@@ -67,57 +67,21 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
67
67
  return { ok: true }
68
68
  }
69
69
 
70
- /** Stash existing work. Returns true if something was stashed. */
71
- export async function gitStash(cwd: string): Promise<boolean> {
72
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" })
73
- const stashOut = await new Response(stash.stdout).text()
74
- const stashExit = await stash.exited
75
- return stashExit === 0 && !stashOut.includes("No local changes")
76
- }
77
-
78
- /** Unstage all changes. */
70
+ /** Unstage all changes so merge results appear as unstaged. */
79
71
  export async function gitReset(cwd: string): Promise<void> {
80
72
  const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
81
73
  await reset.exited
82
74
  }
83
75
 
84
- /** Restore stashed work, preserving staged/unstaged state. Returns true if successful. */
85
- export async function gitStashPop(cwd: string): Promise<boolean> {
86
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" })
87
- const exit = await pop.exited
88
- if (exit !== 0) {
89
- // --index is strict about staged/unstaged distinction — fall back to plain pop
90
- const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" })
91
- const fallbackExit = await fallback.exited
92
- if (fallbackExit !== 0) {
93
- log("merge-helper:stash-pop:failed — stashed work remains in git stash list")
94
- return false
95
- }
96
- }
97
- return true
98
- }
99
-
100
76
  /**
101
- * Stash-safe squash merge of a single branch into the working directory.
102
- * 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.
103
80
  */
104
81
  export async function mergeBranch(branch: string, cwd: string): Promise<MergeResult> {
105
- const didStash = await gitStash(cwd)
106
-
107
82
  const result = await mergeBranchRaw(branch, cwd)
108
- if (!result.ok) {
109
- if (didStash) {
110
- const restored = await gitStashPop(cwd)
111
- if (!restored) return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored — check git stash list` }
112
- }
113
- return result
114
- }
115
-
83
+ if (!result.ok) return result
116
84
  await gitReset(cwd)
117
- if (didStash) {
118
- const restored = await gitStashPop(cwd)
119
- if (!restored) return { ok: true, error: "Merge succeeded but stashed work could not be restored — check git stash list" }
120
- }
121
85
  return { ok: true }
122
86
  }
123
87
 
@@ -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 {
@@ -132,7 +132,7 @@ export async function executeTeamCleanup(
132
132
  parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`)
133
133
  }
134
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")}\nCheck git stash list or git diff to recover.`)
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
136
  }
137
137
  return parts.join("\n")
138
138
  }
@@ -38,7 +38,13 @@ export async function executeTeamMerge(
38
38
  const overlap = await overlapCheck(branch, deps.directory)
39
39
  if (overlap.length > 0) {
40
40
  const files = overlap.map(f => ` - ${f}`).join("\n")
41
- return `Cannot merge ${args.member}'s branch — you have local changes to files that ${args.member} also modified:\n${files}\n\nCommit or stash your changes to these files first, then retry team_merge.\nBranch preserved: ${branch}`
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")
42
48
  }
43
49
  } catch {
44
50
  log(`merge:overlap-check:failed member=${args.member} branch=${branch}`)
@@ -46,7 +52,14 @@ export async function executeTeamMerge(
46
52
 
47
53
  const result = await merge(branch, deps.directory)
48
54
  if (!result.ok) {
49
- 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")
50
63
  }
51
64
 
52
65
  // Merge succeeded — delete the preserved branch and clear DB
@@ -57,6 +70,5 @@ export async function executeTeamMerge(
57
70
  )
58
71
 
59
72
  log(`merge:done member=${args.member} branch=${branch}`)
60
- const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
61
- return result.error ? `${msg}\n\nWarning: ${result.error}` : msg
73
+ return `Merged ${args.member}'s changes into your working directory (unstaged). Review with: git diff`
62
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
  }