@hueyexe/opencode-ensemble 0.12.1 → 0.13.1

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-482%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-492%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.1"]
18
+ "plugin": ["@hueyexe/opencode-ensemble@0.13.1"]
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.1"]
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.1"]
148
148
  }
149
149
  ```
150
150
 
@@ -406,7 +406,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
406
406
  ```bash
407
407
  bun install
408
408
  bun run typecheck
409
- bun test # 482 tests
409
+ bun test # 492 tests
410
410
  bun run build
411
411
  ```
412
412
 
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) {
@@ -15487,7 +15454,8 @@ var MIGRATIONS = [
15487
15454
  `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
15488
15455
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
15489
15456
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
15490
- `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
15457
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
15458
+ `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`
15491
15459
  ];
15492
15460
  function applyMigrations(db) {
15493
15461
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -15609,6 +15577,10 @@ function getUndeliveredMessages(db, teamId) {
15609
15577
  function markDelivered(db, messageId) {
15610
15578
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15611
15579
  }
15580
+ function hasReportedCompletion(db, teamId, memberName) {
15581
+ const row = db.query("SELECT reported_to_lead FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
15582
+ return row?.reported_to_lead === 1;
15583
+ }
15612
15584
 
15613
15585
  // src/recovery.ts
15614
15586
  init_merge_helper();
@@ -15676,6 +15648,10 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15676
15648
  }
15677
15649
  if (!recipientSessionId)
15678
15650
  continue;
15651
+ if (hasReportedCompletion(db, team.id, msg.to_name)) {
15652
+ markDelivered(db, msg.id);
15653
+ continue;
15654
+ }
15679
15655
  try {
15680
15656
  await client3.session.promptAsync({
15681
15657
  sessionID: recipientSessionId,
@@ -15811,10 +15787,16 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
15811
15787
  if (member.status === newStatus)
15812
15788
  return;
15813
15789
  db.run("UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [newStatus, Date.now(), entry.teamId, entry.memberName]);
15790
+ if (member.status === "busy" && newStatus === "ready") {
15791
+ const leadMsgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'").get(entry.teamId, entry.memberName).c;
15792
+ if (leadMsgCount > 0) {
15793
+ db.run("UPDATE team_member SET reported_to_lead = 1 WHERE team_id = ? AND name = ?", [entry.teamId, entry.memberName]);
15794
+ }
15795
+ }
15814
15796
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: newStatus };
15815
15797
  } else if (status === "busy") {
15816
15798
  if (member.status === "ready" || member.status === "error") {
15817
- db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
15799
+ db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
15818
15800
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
15819
15801
  }
15820
15802
  if (member.status === "shutdown_requested") {
@@ -16005,7 +15987,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
16005
15987
  }
16006
15988
  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
15989
  }
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.");
15990
+ 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
15991
  return lines.join(`
16010
15992
  `);
16011
15993
  }
@@ -16877,6 +16859,19 @@ async function executeTeamCreate(deps, args, sessionId) {
16877
16859
  }
16878
16860
 
16879
16861
  // src/tools/shared.ts
16862
+ async function countBranchCommits(branch, cwd) {
16863
+ try {
16864
+ const proc = Bun.spawn(["git", "rev-list", "--count", `HEAD..${branch}`], { cwd, stdout: "pipe", stderr: "pipe" });
16865
+ const out = await new Response(proc.stdout).text();
16866
+ const exit = await proc.exited;
16867
+ if (exit !== 0)
16868
+ return -1;
16869
+ const n = Number.parseInt(out.trim(), 10);
16870
+ return Number.isNaN(n) ? -1 : n;
16871
+ } catch {
16872
+ return -1;
16873
+ }
16874
+ }
16880
16875
  async function checkWorktreeDirty(dir) {
16881
16876
  try {
16882
16877
  const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
@@ -17209,6 +17204,9 @@ async function executeTeamMessage(deps, args, sessionId) {
17209
17204
  });
17210
17205
  return `Message sent to ${args.to}.`;
17211
17206
  }
17207
+ if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
17208
+ return `Message stored for ${args.to} (teammate has completed their task \u2014 message will not wake them).`;
17209
+ }
17212
17210
  const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
17213
17211
  deps.client.session.promptAsync({
17214
17212
  sessionID: recipientSessionId,
@@ -17243,7 +17241,12 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17243
17241
  }
17244
17242
  }
17245
17243
  let delivered = 0;
17244
+ let skipped = 0;
17246
17245
  for (const recipient of recipients) {
17246
+ if (recipient.name !== "lead" && hasReportedCompletion(deps.db, teamInfo.teamId, recipient.name)) {
17247
+ skipped++;
17248
+ continue;
17249
+ }
17247
17250
  deps.client.session.promptAsync({
17248
17251
  sessionID: recipient.sessionId,
17249
17252
  parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
@@ -17255,12 +17258,13 @@ async function executeTeamBroadcast(deps, args, sessionId) {
17255
17258
  log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
17256
17259
  });
17257
17260
  }
17258
- return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
17261
+ const sent = recipients.length - skipped;
17262
+ return `Broadcast sent to ${sent} recipient${sent !== 1 ? "s" : ""}.`;
17259
17263
  }
17260
17264
 
17261
17265
  // src/tools/team-shutdown.ts
17262
17266
  init_merge_helper();
17263
- async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch) {
17267
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch, commitCount = countBranchCommits) {
17264
17268
  const teamInfo = requireLead(deps, sessionId);
17265
17269
  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
17270
  if (!member)
@@ -17270,9 +17274,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17270
17274
  const force = args.force ?? false;
17271
17275
  if (member.status === "shutdown_requested") {
17272
17276
  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}`;
17277
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount);
17278
+ return `Force shut down "${args.member}".${status}`;
17276
17279
  }
17277
17280
  let isIdle = false;
17278
17281
  try {
@@ -17282,9 +17285,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
17282
17285
  } catch {}
17283
17286
  if (isIdle || force) {
17284
17287
  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}`;
17288
+ const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount);
17289
+ return `Teammate "${args.member}" has been shut down.${status}`;
17288
17290
  }
17289
17291
  if (member.worktree_branch) {
17290
17292
  const safeBranch = preservedBranchName(teamInfo.teamName, args.member);
@@ -17322,21 +17324,30 @@ async function preserveAndAbort(deps, teamId, teamName, memberName, sessionId, w
17322
17324
  } catch {}
17323
17325
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
17324
17326
  }
17325
- function getBranchInfo(deps, teamId, memberName) {
17327
+ async function getBranchStatus(deps, teamId, memberName, worktreeDir, isDirty, commitCount) {
17326
17328
  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)
17329
+ if (!row?.worktree_branch)
17331
17330
  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 "";
17331
+ const branch = row.worktree_branch;
17332
+ const parts = [];
17333
+ const commits = await commitCount(branch, deps.directory);
17334
+ const dirty = worktreeDir ? await isDirty(worktreeDir).catch(() => false) : false;
17335
+ if (commits > 0 && dirty) {
17336
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""} and has uncommitted work.`);
17337
+ } else if (commits > 0) {
17338
+ parts.push(`${memberName} committed ${commits} change${commits !== 1 ? "s" : ""}. Ready to merge.`);
17339
+ } else if (dirty) {
17340
+ parts.push(`${memberName} has uncommitted changes only \u2014 their work may be incomplete.`);
17341
+ } else if (commits < 0) {
17342
+ parts.push(`Could not determine ${memberName}'s commit status. Merge to check their work.`);
17343
+ } else {
17344
+ parts.push(`${memberName} made no changes.`);
17345
+ }
17346
+ parts.push(`Branch: ${branch}`);
17347
+ parts.push("Use team_merge to merge their work.");
17348
+ return `
17349
+ ${parts.join(`
17350
+ `)}`;
17340
17351
  }
17341
17352
 
17342
17353
  // src/tools/team-cleanup.ts
@@ -17438,7 +17449,7 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17438
17449
  parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:
17439
17450
  ${overlapWarnings.map((w) => ` - ${w}`).join(`
17440
17451
  `)}
17441
- Check git stash list or git diff to recover.`);
17452
+ Review with: git diff`);
17442
17453
  }
17443
17454
  return parts.join(`
17444
17455
  `);
@@ -17464,29 +17475,34 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
17464
17475
  if (overlap.length > 0) {
17465
17476
  const files = overlap.map((f) => ` - ${f}`).join(`
17466
17477
  `);
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}`;
17478
+ return [
17479
+ `Cannot merge ${args.member} \u2014 you have local changes to the same files:`,
17480
+ files,
17481
+ ``,
17482
+ `Commit or stash your changes first, then retry team_merge.`,
17483
+ `Branch preserved: ${branch}`
17484
+ ].join(`
17485
+ `);
17472
17486
  }
17473
17487
  } catch {
17474
17488
  log(`merge:overlap-check:failed member=${args.member} branch=${branch}`);
17475
17489
  }
17476
17490
  const result = await merge2(branch, deps.directory);
17477
17491
  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}`;
17492
+ return [
17493
+ `Merge conflict merging ${args.member}'s branch (${branch}).`,
17494
+ `Resolve manually:`,
17495
+ ` git merge --squash ${branch}`,
17496
+ ` git reset HEAD`,
17497
+ ``,
17498
+ `Error: ${result.error}`
17499
+ ].join(`
17500
+ `);
17482
17501
  }
17483
17502
  await delBranch(branch, deps.directory);
17484
17503
  deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
17485
17504
  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;
17505
+ return `Merged ${args.member}'s changes into your working directory (unstaged). Review with: git diff`;
17490
17506
  }
17491
17507
 
17492
17508
  // src/tools/team-tasks-list.ts
@@ -17549,6 +17565,20 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
17549
17565
  unblocked++;
17550
17566
  }
17551
17567
  }
17568
+ const counts = deps.db.query("SELECT status, COUNT(*) as c FROM team_task WHERE team_id = ? GROUP BY status").all(teamInfo.teamId);
17569
+ const completed = counts.find((r) => r.status === "completed")?.c ?? 0;
17570
+ const total = counts.reduce((sum, r) => sum + r.c, 0);
17571
+ const who = teamInfo.memberName ?? "teammate";
17572
+ try {
17573
+ deps.client.tui.showToast({
17574
+ title: "Team",
17575
+ message: `${who}: ${completed}/${total} tasks complete`,
17576
+ variant: "info",
17577
+ duration: 3000
17578
+ }).catch(() => {});
17579
+ } catch {
17580
+ log(`tasks-complete:toast:failed`);
17581
+ }
17552
17582
  const unblockedMsg = unblocked > 0 ? ` Unblocked ${unblocked} dependent task${unblocked !== 1 ? "s" : ""}.` : "";
17553
17583
  return `Completed task: ${task.content}${unblockedMsg}`;
17554
17584
  }
@@ -17924,6 +17954,8 @@ var plugin = async (input) => {
17924
17954
  const tracker = new DescendantTracker;
17925
17955
  const nudgedMembers = new Set;
17926
17956
  const progressTracker = new ProgressTracker;
17957
+ const wakeLeadTimestamps = new Map;
17958
+ const WAKE_LEAD_COOLDOWN_MS = 5000;
17927
17959
  const pluginTransport = input.client._client;
17928
17960
  const rawClient = new OpencodeClient({ client: pluginTransport });
17929
17961
  initLog(rawClient);
@@ -18014,7 +18046,7 @@ var plugin = async (input) => {
18014
18046
  }
18015
18047
  }
18016
18048
  const nudgeKey = `${transition.teamId}:${transition.memberName}`;
18017
- if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
18049
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
18018
18050
  nudgedMembers.add(nudgeKey);
18019
18051
  log(`nudge:idle-without-report name=${transition.memberName}`);
18020
18052
  client3.session.promptAsync({
@@ -18057,7 +18089,10 @@ var plugin = async (input) => {
18057
18089
  const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
18058
18090
  if (team) {
18059
18091
  const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
18060
- if (pending.c > 0) {
18092
+ const allDone = db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ? AND status NOT IN ('ready', 'shutdown', 'error')").get(team.id).c === 0;
18093
+ const lastWake = wakeLeadTimestamps.get(team.id) ?? 0;
18094
+ if (pending.c > 0 && !allDone && Date.now() - lastWake > WAKE_LEAD_COOLDOWN_MS) {
18095
+ wakeLeadTimestamps.set(team.id, Date.now());
18061
18096
  log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
18062
18097
  client3.session.promptAsync({
18063
18098
  sessionID,
@@ -18070,7 +18105,7 @@ var plugin = async (input) => {
18070
18105
  const member = db.query(`SELECT tm.team_id, tm.name FROM team_member tm
18071
18106
  JOIN team t ON tm.team_id = t.id
18072
18107
  WHERE tm.session_id = ? AND t.status = 'active'`).get(sessionID);
18073
- if (member) {
18108
+ if (member && !hasReportedCompletion(db, member.team_id, member.name)) {
18074
18109
  const staleThreshold = Date.now() - 5000;
18075
18110
  const peerMsgs = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?").get(member.team_id, member.name, staleThreshold);
18076
18111
  if (peerMsgs.c > 0) {
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.1",
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",
package/src/hooks.ts CHANGED
@@ -41,11 +41,26 @@ export function handleSessionStatusEvent(
41
41
  "UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
42
42
  [newStatus, Date.now(), entry.teamId, entry.memberName]
43
43
  )
44
+ // Mark teammate as having reported if they sent at least one message to lead (issue #3).
45
+ // Set on busy→ready transition so Q&A messages during work don't prematurely block delivery.
46
+ if (member.status === "busy" && newStatus === "ready") {
47
+ const leadMsgCount = (db.query(
48
+ "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'"
49
+ ).get(entry.teamId, entry.memberName) as { c: number }).c
50
+ if (leadMsgCount > 0) {
51
+ db.run(
52
+ "UPDATE team_member SET reported_to_lead = 1 WHERE team_id = ? AND name = ?",
53
+ [entry.teamId, entry.memberName]
54
+ )
55
+ }
56
+ }
44
57
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: newStatus }
45
58
  } else if (status === "busy") {
46
59
  if (member.status === "ready" || member.status === "error") {
60
+ // Reset reported_to_lead so re-activated teammates can receive messages again (issue #3).
61
+ // INVARIANT: every promptAsync delivery path must check hasReportedCompletion() to prevent loops.
47
62
  db.run(
48
- "UPDATE team_member SET status = 'busy', execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
63
+ "UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?",
49
64
  [Date.now(), entry.teamId, entry.memberName]
50
65
  )
51
66
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { MemberRegistry, DescendantTracker } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
12
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
- import { sendMessage } from "./messaging"
13
+ import { sendMessage, hasReportedCompletion } from "./messaging"
14
14
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
15
15
  import { log, initLog } from "./log"
16
16
  import { findTeamBySession } from "./types"
@@ -58,6 +58,8 @@ const plugin: Plugin = async (input) => {
58
58
  const tracker = new DescendantTracker()
59
59
  const nudgedMembers = new Set<string>()
60
60
  const progressTracker = new ProgressTracker()
61
+ const wakeLeadTimestamps = new Map<string, number>()
62
+ const WAKE_LEAD_COOLDOWN_MS = 5000
61
63
 
62
64
  // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
63
65
  // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
@@ -176,8 +178,9 @@ const plugin: Plugin = async (input) => {
176
178
  }
177
179
 
178
180
  // Nudge teammate if they went idle without reporting to the lead (once only)
181
+ // Skip if they already reported completion (issue #3 — prevents re-waking completed teammates)
179
182
  const nudgeKey = `${transition.teamId}:${transition.memberName}`
180
- if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
183
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
181
184
  nudgedMembers.add(nudgeKey)
182
185
  log(`nudge:idle-without-report name=${transition.memberName}`)
183
186
  client.session.promptAsync({
@@ -232,7 +235,13 @@ const plugin: Plugin = async (input) => {
232
235
  const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID) as { id: string } | null
233
236
  if (team) {
234
237
  const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id) as { c: number }
235
- if (pending.c > 0) {
238
+ // Skip wake if all teammates are done or if we woke recently (issue #3 — breaks completion loop)
239
+ const allDone = (db.query(
240
+ "SELECT COUNT(*) as c FROM team_member WHERE team_id = ? AND status NOT IN ('ready', 'shutdown', 'error')"
241
+ ).get(team.id) as { c: number }).c === 0
242
+ const lastWake = wakeLeadTimestamps.get(team.id) ?? 0
243
+ if (pending.c > 0 && !allDone && Date.now() - lastWake > WAKE_LEAD_COOLDOWN_MS) {
244
+ wakeLeadTimestamps.set(team.id, Date.now())
236
245
  log(`wake-lead: ${pending.c} pending messages, sending promptAsync`)
237
246
  client.session.promptAsync({
238
247
  sessionID,
@@ -250,7 +259,7 @@ const plugin: Plugin = async (input) => {
250
259
  JOIN team t ON tm.team_id = t.id
251
260
  WHERE tm.session_id = ? AND t.status = 'active'`
252
261
  ).get(sessionID) as { team_id: string; name: string } | null
253
- if (member) {
262
+ if (member && !hasReportedCompletion(db, member.team_id, member.name)) {
254
263
  const staleThreshold = Date.now() - 5000
255
264
  const peerMsgs = db.query(
256
265
  "SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
package/src/messaging.ts CHANGED
@@ -76,3 +76,14 @@ export function getUndeliveredMessages(db: Database, teamId: string): MessageRow
76
76
  export function markDelivered(db: Database, messageId: string): void {
77
77
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
78
78
  }
79
+
80
+ /**
81
+ * Check if a teammate has reported completion to the lead.
82
+ * Returns true if the reported_to_lead flag is set on the member.
83
+ */
84
+ export function hasReportedCompletion(db: Database, teamId: string, memberName: string): boolean {
85
+ const row = db.query(
86
+ "SELECT reported_to_lead FROM team_member WHERE team_id = ? AND name = ?"
87
+ ).get(teamId, memberName) as { reported_to_lead: number } | null
88
+ return row?.reported_to_lead === 1
89
+ }
package/src/recovery.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
- import { getUndeliveredMessages, markDelivered } from "./messaging"
4
+ import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
5
5
  import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
6
  import { log } from "./log"
7
7
 
@@ -123,6 +123,12 @@ export async function recoverUndeliveredMessages(
123
123
 
124
124
  if (!recipientSessionId) continue
125
125
 
126
+ // Skip delivery to teammates who have already reported completion (issue #3)
127
+ if (hasReportedCompletion(db, team.id, msg.to_name!)) {
128
+ markDelivered(db, msg.id)
129
+ continue
130
+ }
131
+
126
132
  try {
127
133
  await client.session.promptAsync({
128
134
  sessionID: recipientSessionId,
package/src/schema.ts CHANGED
@@ -80,6 +80,8 @@ export const MIGRATIONS: string[] = [
80
80
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
81
81
  // Migration 6: Track workspace ID for worktree-session binding
82
82
  `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
83
+ // Migration 7: Track whether teammate has reported to lead (completion loop prevention, issue #3)
84
+ `ALTER TABLE team_member ADD COLUMN reported_to_lead INTEGER NOT NULL DEFAULT 0;`,
83
85
  ]
84
86
 
85
87
  /**
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
- import { broadcastMessage, markDelivered } from "../messaging"
3
+ import { broadcastMessage, markDelivered, hasReportedCompletion } from "../messaging"
4
4
  import { log } from "../log"
5
5
 
6
6
  /**
@@ -40,8 +40,14 @@ export async function executeTeamBroadcast(
40
40
  }
41
41
 
42
42
  // Fire-and-forget: deliver to all recipients. Message is already persisted in DB.
43
+ // Skip completed teammates to prevent re-waking them (issue #3).
43
44
  let delivered = 0
45
+ let skipped = 0
44
46
  for (const recipient of recipients) {
47
+ if (recipient.name !== "lead" && hasReportedCompletion(deps.db, teamInfo.teamId, recipient.name)) {
48
+ skipped++
49
+ continue
50
+ }
45
51
  deps.client.session.promptAsync({
46
52
  sessionID: recipient.sessionId,
47
53
  parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
@@ -53,5 +59,6 @@ export async function executeTeamBroadcast(
53
59
  })
54
60
  }
55
61
 
56
- return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
62
+ const sent = recipients.length - skipped
63
+ return `Broadcast sent to ${sent} recipient${sent !== 1 ? "s" : ""}.`
57
64
  }
@@ -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,7 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { resolveRecipientSession } from "../types"
3
3
  import { requireTeamMember } from "./shared"
4
- import { sendMessage, markDelivered } from "../messaging"
4
+ import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
5
5
  import { log } from "../log"
6
6
 
7
7
  /**
@@ -90,6 +90,11 @@ export async function executeTeamMessage(
90
90
  return `Message sent to ${args.to}.`
91
91
  }
92
92
 
93
+ // Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
94
+ if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
95
+ return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`
96
+ }
97
+
93
98
  // For member-to-member messages, fire-and-forget delivery is safe.
94
99
  const deliveryText = `[Team message from ${senderName}]: ${messageText}`
95
100
  deps.client.session.promptAsync({
@@ -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
  }