@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 +7 -6
- package/dist/index.js +115 -62
- package/package.json +1 -1
- package/src/system-prompt.ts +3 -4
- package/src/tools/merge-helper.ts +27 -41
- package/src/tools/shared.ts +15 -0
- package/src/tools/team-cleanup.ts +14 -2
- package/src/tools/team-merge.ts +29 -4
- package/src/tools/team-shutdown.ts +39 -21
- package/src/tools/team-tasks-complete.ts +18 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
4
4
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
7
7
|
[]()
|
|
8
8
|
[](./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.
|
|
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.
|
|
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.
|
|
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 #
|
|
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("", "
|
|
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
|
|
17257
|
-
|
|
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
|
|
17269
|
-
|
|
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
|
|
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
|
-
|
|
17311
|
-
}
|
|
17312
|
-
async function getDirtyWarning(worktreeDir, memberName, isDirty) {
|
|
17313
|
-
if (!worktreeDir)
|
|
17305
|
+
if (!row?.worktree_branch)
|
|
17314
17306
|
return "";
|
|
17315
|
-
|
|
17316
|
-
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17320
|
-
}
|
|
17321
|
-
}
|
|
17322
|
-
|
|
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
|
|
17435
|
-
|
|
17436
|
-
|
|
17437
|
-
|
|
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.
|
|
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",
|
package/src/system-prompt.ts
CHANGED
|
@@ -133,9 +133,8 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
|
|
|
133
133
|
|
|
134
134
|
lines.push(
|
|
135
135
|
"",
|
|
136
|
-
"
|
|
137
|
-
"
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
*/
|
package/src/tools/shared.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
|
|
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
|
|
51
|
-
|
|
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
|
-
/**
|
|
133
|
-
function
|
|
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
|
-
|
|
137
|
-
}
|
|
142
|
+
if (!row?.worktree_branch) return ""
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
}
|