@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 +5 -5
- package/dist/index.js +110 -75
- package/package.json +1 -1
- package/src/hooks.ts +16 -1
- package/src/index.ts +13 -4
- package/src/messaging.ts +11 -0
- package/src/recovery.ts +7 -1
- package/src/schema.ts +2 -0
- 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-broadcast.ts +9 -2
- package/src/tools/team-cleanup.ts +1 -1
- package/src/tools/team-merge.ts +16 -4
- package/src/tools/team-message.ts +6 -1
- 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.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.
|
|
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.
|
|
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 #
|
|
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("", "
|
|
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
|
-
|
|
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
|
|
17274
|
-
|
|
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
|
|
17286
|
-
|
|
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
|
|
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
|
-
|
|
17328
|
-
}
|
|
17329
|
-
async function getDirtyWarning(worktreeDir, memberName, isDirty) {
|
|
17330
|
-
if (!worktreeDir)
|
|
17329
|
+
if (!row?.worktree_branch)
|
|
17331
17330
|
return "";
|
|
17332
|
-
|
|
17333
|
-
|
|
17334
|
-
|
|
17335
|
-
|
|
17336
|
-
|
|
17337
|
-
}
|
|
17338
|
-
}
|
|
17339
|
-
|
|
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
|
-
|
|
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
|
|
17468
|
-
${files
|
|
17469
|
-
|
|
17470
|
-
|
|
17471
|
-
|
|
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
|
|
17479
|
-
|
|
17480
|
-
|
|
17481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
/**
|
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 {
|
|
@@ -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
|
-
|
|
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")}\
|
|
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,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
|
|
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
|
}
|