@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 +3 -3
- package/dist/index.js +75 -69
- package/package.json +1 -1
- package/src/system-prompt.ts +3 -4
- package/src/tools/merge-helper.ts +5 -41
- package/src/tools/shared.ts +15 -0
- package/src/tools/team-cleanup.ts +1 -1
- package/src/tools/team-merge.ts +16 -4
- package/src/tools/team-shutdown.ts +39 -21
- package/src/tools/team-tasks-complete.ts +18 -2
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.
|
|
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
|
|
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("", "
|
|
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
|
|
17274
|
-
|
|
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
|
|
17286
|
-
|
|
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
|
|
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
|
-
|
|
17328
|
-
}
|
|
17329
|
-
async function getDirtyWarning(worktreeDir, memberName, isDirty) {
|
|
17330
|
-
if (!worktreeDir)
|
|
17305
|
+
if (!row?.worktree_branch)
|
|
17331
17306
|
return "";
|
|
17332
|
-
|
|
17333
|
-
|
|
17334
|
-
|
|
17335
|
-
|
|
17336
|
-
|
|
17337
|
-
}
|
|
17338
|
-
}
|
|
17339
|
-
|
|
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
|
-
|
|
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
|
|
17468
|
-
${files
|
|
17469
|
-
|
|
17470
|
-
|
|
17471
|
-
|
|
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
|
|
17479
|
-
|
|
17480
|
-
|
|
17481
|
-
|
|
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
|
-
|
|
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.
|
|
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.",
|
|
@@ -67,57 +67,21 @@ export async function mergeBranchRaw(branch: string, cwd: string): Promise<Merge
|
|
|
67
67
|
return { ok: true }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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
|
|
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 {
|
|
@@ -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")}\
|
|
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
|
}
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|