@hueyexe/opencode-ensemble 0.9.0 → 0.10.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.
@@ -0,0 +1,166 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { DASHBOARD_HEAD } from "./dashboard-html"
3
+ import { DASHBOARD_JS_PART1 } from "./dashboard-js-part1"
4
+ import { DASHBOARD_JS_PART2 } from "./dashboard-js-part2"
5
+ import { DASHBOARD_JS_PART3 } from "./dashboard-js-part3"
6
+ import { log } from "./log"
7
+
8
+ /** Assemble the full dashboard HTML from parts. */
9
+ const DASHBOARD_HTML = DASHBOARD_HEAD + "\n<script>" + DASHBOARD_JS_PART1 + DASHBOARD_JS_PART2 + DASHBOARD_JS_PART3 + "<\/script>\n</body></html>"
10
+
11
+ interface TeamRow {
12
+ id: string
13
+ name: string
14
+ status: string
15
+ lead_agent: string | null
16
+ time_created: number
17
+ time_updated: number
18
+ }
19
+
20
+ interface MemberRow {
21
+ name: string
22
+ agent: string
23
+ status: string
24
+ execution_status: string
25
+ worktree_branch: string | null
26
+ prompt: string | null
27
+ model: string | null
28
+ plan_approval: string
29
+ time_created: number
30
+ time_updated: number
31
+ }
32
+
33
+ interface TaskRow {
34
+ id: string
35
+ content: string
36
+ status: string
37
+ priority: string
38
+ assignee: string | null
39
+ depends_on: string | null
40
+ time_created: number
41
+ time_updated: number
42
+ }
43
+
44
+ interface MessageRow {
45
+ id: string
46
+ from_name: string
47
+ to_name: string | null
48
+ content: string
49
+ delivered: number
50
+ read: number
51
+ time_created: number
52
+ }
53
+
54
+ function jsonResponse(data: unknown, status = 200): Response {
55
+ return new Response(JSON.stringify(data), {
56
+ status,
57
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
58
+ })
59
+ }
60
+
61
+ function buildState(db: Database): { teams: unknown[] } {
62
+ const teams = db.query("SELECT id, name, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
63
+ const memberStmt = db.query("SELECT name, agent, status, execution_status, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?")
64
+ const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
65
+ const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50")
66
+
67
+ return {
68
+ teams: teams.map((t) => ({
69
+ id: t.id,
70
+ name: t.name,
71
+ status: t.status,
72
+ leadAgent: t.lead_agent,
73
+ timeCreated: t.time_created,
74
+ timeUpdated: t.time_updated,
75
+ members: (memberStmt.all(t.id) as MemberRow[]).map((m) => ({
76
+ name: m.name,
77
+ agent: m.agent,
78
+ status: m.status,
79
+ executionStatus: m.execution_status,
80
+ worktreeBranch: m.worktree_branch,
81
+ prompt: m.prompt,
82
+ model: m.model,
83
+ planApproval: m.plan_approval,
84
+ timeCreated: m.time_created,
85
+ timeUpdated: m.time_updated,
86
+ })),
87
+ tasks: (taskStmt.all(t.id) as TaskRow[]).map((tk) => ({
88
+ id: tk.id,
89
+ content: tk.content,
90
+ status: tk.status,
91
+ priority: tk.priority,
92
+ assignee: tk.assignee,
93
+ dependsOn: tk.depends_on,
94
+ timeCreated: tk.time_created,
95
+ timeUpdated: tk.time_updated,
96
+ })),
97
+ messages: (msgStmt.all(t.id) as MessageRow[]).map((msg) => ({
98
+ id: msg.id,
99
+ fromName: msg.from_name,
100
+ toName: msg.to_name,
101
+ content: msg.content,
102
+ delivered: msg.delivered === 1,
103
+ read: msg.read === 1,
104
+ timeCreated: msg.time_created,
105
+ })),
106
+ })),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Start the dashboard HTTP server.
112
+ * Serves a JSON API for team state and the dashboard HTML.
113
+ * Singleton: if the port is already in use by another ensemble instance, skips silently.
114
+ * Returns the server instance, or null if skipped.
115
+ */
116
+ export async function startDashboard(db: Database, port: number): Promise<ReturnType<typeof Bun.serve> | null> {
117
+ try {
118
+ const server = Bun.serve({
119
+ port,
120
+ fetch(req) {
121
+ const url = new URL(req.url)
122
+
123
+ if (url.pathname === "/api/health") {
124
+ return jsonResponse({ ensemble: true, pid: process.pid })
125
+ }
126
+
127
+ if (url.pathname === "/api/state") {
128
+ return jsonResponse(buildState(db))
129
+ }
130
+
131
+ if (url.pathname === "/") {
132
+ return new Response(DASHBOARD_HTML, {
133
+ headers: { "Content-Type": "text/html" },
134
+ })
135
+ }
136
+
137
+ return new Response("Not Found", { status: 404 })
138
+ },
139
+ })
140
+ log(`dashboard:started port=${port} url=http://localhost:${port}`)
141
+ return server
142
+ } catch (err) {
143
+ if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
144
+ try {
145
+ const res = await fetch(`http://localhost:${port}/api/health`)
146
+ const data = await res.json() as { ensemble?: boolean; pid?: number }
147
+ if (data.ensemble && data.pid) {
148
+ // Check if the other process is still alive
149
+ let alive = false
150
+ try { process.kill(data.pid, 0); alive = true } catch { /* process is dead */ }
151
+ if (alive && data.pid !== process.pid) {
152
+ log(`dashboard:already-running port=${port} pid=${data.pid}`)
153
+ return null
154
+ }
155
+ // Stale server from a dead process — warn the user
156
+ log(`dashboard:stale-server port=${port} stale-pid=${data.pid} — run: kill -9 ${data.pid} || lsof -ti:${port} | xargs kill -9`)
157
+ return null
158
+ }
159
+ } catch { /* health check failed — port held by something else */ }
160
+ log(`dashboard:port-in-use port=${port} (not an ensemble instance)`)
161
+ return null
162
+ }
163
+ log(`dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
164
+ return null
165
+ }
166
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import path from "node:path"
5
5
  import { mkdirSync } from "node:fs"
6
6
  import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
- import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
8
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
@@ -15,12 +15,14 @@ import { log, initLog } from "./log"
15
15
  import { findTeamBySession } from "./types"
16
16
  import { loadConfig } from "./config"
17
17
  import { ProgressTracker } from "./progress"
18
+ import { startDashboard } from "./dashboard"
18
19
  import { executeTeamCreate } from "./tools/team-create"
19
20
  import { executeTeamSpawn } from "./tools/team-spawn"
20
21
  import { executeTeamMessage } from "./tools/team-message"
21
22
  import { executeTeamBroadcast } from "./tools/team-broadcast"
22
23
  import { executeTeamShutdown } from "./tools/team-shutdown"
23
24
  import { executeTeamCleanup } from "./tools/team-cleanup"
25
+ import { executeTeamMerge } from "./tools/team-merge"
24
26
  import { executeTeamTasksList } from "./tools/team-tasks-list"
25
27
  import { executeTeamTasksAdd } from "./tools/team-tasks-add"
26
28
  import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
@@ -71,7 +73,7 @@ const plugin: Plugin = async (input) => {
71
73
  // calls back to the server, which deadlocks because the server is still handling session.create.
72
74
  if (!isWorktreeInstance(input.directory)) {
73
75
  log("init:recovery:start (main instance)")
74
- const recovery = await recoverStaleMembers(db, client)
76
+ const recovery = await recoverStaleMembers(db, client, input.directory)
75
77
  if (recovery.interrupted > 0) {
76
78
  log(`init:recovery:interrupted=${recovery.interrupted}`)
77
79
  const members = db.query(
@@ -88,7 +90,17 @@ const plugin: Plugin = async (input) => {
88
90
  recoverOrphanedWorktrees(db, client).catch((err) => {
89
91
  log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`)
90
92
  })
93
+ recoverOrphanedBranches(db, input.directory).catch((err) => {
94
+ log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`)
95
+ })
91
96
  log("init:recovery:done")
97
+
98
+ // Start dashboard server (main instance only, not worktree instances)
99
+ if (config.dashboardPort !== 0) {
100
+ startDashboard(db, config.dashboardPort).catch((err) => {
101
+ log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
102
+ })
103
+ }
92
104
  } else {
93
105
  log(`init:skip-recovery (worktree instance: ${input.directory})`)
94
106
  }
@@ -109,6 +121,7 @@ const plugin: Plugin = async (input) => {
109
121
  stallThresholdMs: config.stallThresholdMs,
110
122
  stallMinSteps: config.stallMinSteps,
111
123
  stallTokenThreshold: config.stallTokenThreshold,
124
+ cwd: input.directory,
112
125
  })
113
126
  watchdog.start()
114
127
 
@@ -150,12 +163,27 @@ const plugin: Plugin = async (input) => {
150
163
  } catch { /* TUI may not be available */ }
151
164
  } else if (transition.to === "busy_while_shutdown") {
152
165
  // Session went busy after shutdown was requested — re-issue abort
153
- const entry = registry.getBySession(sessionID)
154
- if (entry) {
155
- try {
156
- await client.session.abort({ sessionID })
157
- } catch { /* best effort */ }
166
+ // Branch should already be preserved by the graceful shutdown path,
167
+ // but verify and re-preserve if needed
168
+ const member = deps.db.query(
169
+ "SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?"
170
+ ).get(sessionID) as { worktree_branch: string | null; name: string; team_id: string } | null
171
+ if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
172
+ const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id) as { name: string } | null
173
+ if (teamRow) {
174
+ const { preserveBranch: preserve, preservedBranchName: branchName } = await import("./tools/merge-helper")
175
+ const safeBranch = branchName(teamRow.name, member.name)
176
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
177
+ if (ok) {
178
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
179
+ [safeBranch, member.team_id, member.name])
180
+ log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
181
+ }
182
+ }
158
183
  }
184
+ try {
185
+ await client.session.abort({ sessionID })
186
+ } catch { /* best effort */ }
159
187
  }
160
188
 
161
189
  // Show working progress after every transition so the user sees who's still active
@@ -406,13 +434,28 @@ const plugin: Plugin = async (input) => {
406
434
  acknowledge_uncommitted: tool.schema.boolean().default(false),
407
435
  },
408
436
  async execute(args, ctx) {
409
- const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config.mergeOnCleanup)
437
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup)
410
438
  const blocked = result.includes("uncommitted")
411
439
  ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
412
440
  return result
413
441
  },
414
442
  }),
415
443
 
444
+ team_merge: tool({
445
+ description: "Merge a shutdown teammate's branch into the working directory as unstaged changes. " +
446
+ "Use this after team_shutdown to review and integrate a teammate's work. " +
447
+ "The teammate must be shut down first.",
448
+ args: {
449
+ member: tool.schema.string().describe("Teammate name whose branch to merge"),
450
+ },
451
+ async execute(args, ctx) {
452
+ const result = await executeTeamMerge(deps, args, ctx.sessionID)
453
+ const conflict = result.includes("conflict")
454
+ ctx.metadata({ title: conflict ? `Merge conflict: ${args.member}` : `Merged ${args.member}` })
455
+ return result
456
+ },
457
+ }),
458
+
416
459
  team_status: tool({
417
460
  description: "View team members with their current status, agent type, and session IDs. " +
418
461
  "Use this to check who is working, idle, or shut down. Includes a task summary.",
package/src/recovery.ts CHANGED
@@ -2,21 +2,24 @@ import type { Database } from "bun:sqlite"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import { getUndeliveredMessages, markDelivered } from "./messaging"
5
+ import { preserveBranch, preservedBranchName } from "./tools/merge-helper"
6
+ import { log } from "./log"
5
7
 
6
8
  /**
7
9
  * Scan for team members stuck in 'busy' status (stale from a crash)
8
10
  * and mark them as 'error' with execution_status 'idle'.
9
- * Also aborts their orphaned sessions (best effort).
11
+ * Preserves worktree branches before aborting orphaned sessions.
10
12
  * Only processes members in active teams.
11
13
  * Returns the count of interrupted members.
12
14
  */
13
- export async function recoverStaleMembers(db: Database, client?: PluginClient): Promise<{ interrupted: number }> {
14
- // Find stale members before updating so we can abort their sessions
15
+ export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
16
+ // Find stale members with branch info so we can preserve before aborting
15
17
  const stale = db.query(
16
- `SELECT tm.session_id FROM team_member tm
18
+ `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
19
+ FROM team_member tm
17
20
  JOIN team t ON tm.team_id = t.id
18
21
  WHERE tm.status = 'busy' AND t.status = 'active'`
19
- ).all() as Array<{ session_id: string }>
22
+ ).all() as Array<{ session_id: string; worktree_branch: string | null; name: string; team_id: string; team_name: string }>
20
23
 
21
24
  const result = db.run(
22
25
  `UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
@@ -25,9 +28,19 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
25
28
  [Date.now()]
26
29
  )
27
30
 
28
- // Abort orphaned sessions (best effort)
31
+ // Preserve branches then abort orphaned sessions
29
32
  if (client) {
30
33
  for (const member of stale) {
34
+ // Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
35
+ if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
36
+ const safeBranch = preservedBranchName(member.team_name, member.name)
37
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd)
38
+ if (ok) {
39
+ db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
40
+ [safeBranch, member.team_id, member.name])
41
+ log(`recovery:branch:preserved src=${member.worktree_branch} target=${safeBranch}`)
42
+ }
43
+ }
31
44
  try {
32
45
  await client.session.abort({ sessionID: member.session_id })
33
46
  } catch { /* best effort */ }
@@ -125,3 +138,53 @@ export async function recoverUndeliveredMessages(
125
138
 
126
139
  return { redelivered }
127
140
  }
141
+
142
+ /**
143
+ * Clean up orphaned ensemble/preserved/* branches that belong to archived teams
144
+ * with no active members. Scoped carefully to avoid interfering with other
145
+ * running OpenCode sessions that may have active teams.
146
+ */
147
+ export async function recoverOrphanedBranches(db: Database, cwd: string): Promise<{ removed: number }> {
148
+ let removed = 0
149
+
150
+ // Get archived team names that have NO active members
151
+ const archivedTeams = db.query(
152
+ `SELECT t.name FROM team t
153
+ WHERE t.status = 'archived'
154
+ AND NOT EXISTS (
155
+ SELECT 1 FROM team_member tm
156
+ WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
157
+ )`
158
+ ).all() as Array<{ name: string }>
159
+
160
+ if (archivedTeams.length === 0) return { removed: 0 }
161
+
162
+ const archivedNames = new Set(archivedTeams.map(t => t.name))
163
+
164
+ // List all local branches matching ensemble/preserved/*
165
+ const proc = Bun.spawn(["git", "branch", "--list", "ensemble/preserved/*"], { cwd, stdout: "pipe", stderr: "pipe" })
166
+ const stdoutPromise = new Response(proc.stdout).text()
167
+ await proc.exited
168
+ const stdout = await stdoutPromise
169
+
170
+ const branches = stdout.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean)
171
+
172
+ for (const branch of branches) {
173
+ // Parse team name from branch: ensemble/preserved/{teamName}/{memberName}
174
+ const parts = branch.split("/")
175
+ if (parts.length < 4) continue
176
+ const teamName = parts[2]
177
+ if (!teamName || !archivedNames.has(teamName)) continue
178
+
179
+ try {
180
+ const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
181
+ const exitCode = await del.exited
182
+ if (exitCode === 0) {
183
+ removed++
184
+ log(`recovery:branch:deleted branch=${branch}`)
185
+ }
186
+ } catch { /* best effort */ }
187
+ }
188
+
189
+ return { removed }
190
+ }
@@ -109,6 +109,12 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
109
109
  "After spawning all teammates, tell the user what you've set up and wait.",
110
110
  "When all teammates finish, summarize results and suggest next steps.",
111
111
  "",
112
+ "MERGE WORKFLOW:",
113
+ "After a teammate finishes and you shut them down, use team_merge to merge their branch.",
114
+ "Do NOT tell teammates to commit — they handle that themselves.",
115
+ "Do NOT run git merge manually — use team_merge which handles stashing and unstaging.",
116
+ "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.",
117
+ "",
112
118
  "Before calling team_cleanup, verify teammates have committed their work.",
113
119
  "team_shutdown will warn you if a teammate has uncommitted changes.",
114
120
  "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
@@ -0,0 +1,126 @@
1
+ import { log } from "../log"
2
+
3
+ /** Result of merging a single branch. */
4
+ export interface MergeResult {
5
+ ok: boolean
6
+ error?: string
7
+ }
8
+
9
+ /** Injectable function for testing. */
10
+ export type MergeBranchFn = (branch: string, cwd: string) => Promise<MergeResult>
11
+
12
+ /** Injectable function for preserving a branch before worktree deletion. */
13
+ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd: string) => Promise<boolean>
14
+
15
+ /** Injectable function for deleting a branch. */
16
+ export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
17
+
18
+ /**
19
+ * Copy a git branch to a new ref. Used to preserve worktree branches
20
+ * before session.abort() which may delete the worktree and its branch.
21
+ * Returns true if the branch was successfully copied.
22
+ */
23
+ export async function preserveBranch(sourceBranch: string, targetBranch: string, cwd: string): Promise<boolean> {
24
+ const proc = Bun.spawn(["git", "branch", targetBranch, sourceBranch], { cwd, stdout: "pipe", stderr: "pipe" })
25
+ const exit = await proc.exited
26
+ if (exit !== 0) {
27
+ const stderr = await new Response(proc.stderr).text()
28
+ log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${stderr.trim()}`)
29
+ return false
30
+ }
31
+ return true
32
+ }
33
+
34
+ /**
35
+ * Delete a git branch. Returns true if successful.
36
+ */
37
+ export async function deleteBranch(branch: string, cwd: string): Promise<boolean> {
38
+ const proc = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" })
39
+ const exit = await proc.exited
40
+ if (exit !== 0) {
41
+ log(`merge-helper:delete:failed branch=${branch}`)
42
+ return false
43
+ }
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Raw squash merge of a single branch. No stash/pop — caller handles that.
49
+ * Used by mergeBranch (single) and mergeMultipleBranches (batch).
50
+ */
51
+ export async function mergeBranchRaw(branch: string, cwd: string): Promise<MergeResult> {
52
+ const merge = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" })
53
+ const stderrPromise = new Response(merge.stderr).text()
54
+ const mergeExit = await merge.exited
55
+
56
+ if (mergeExit !== 0) {
57
+ const stderr = await stderrPromise
58
+ log(`merge-helper:merge:conflict branch=${branch} err=${stderr.trim()}`)
59
+ const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" })
60
+ await abort.exited
61
+ return { ok: false, error: stderr.trim() || `merge exited with code ${mergeExit}` }
62
+ }
63
+
64
+ return { ok: true }
65
+ }
66
+
67
+ /** Stash existing work. Returns true if something was stashed. */
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. */
76
+ export async function gitReset(cwd: string): Promise<void> {
77
+ const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" })
78
+ await reset.exited
79
+ }
80
+
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
+ /**
98
+ * Stash-safe squash merge of a single branch into the working directory.
99
+ * Stashes existing work, merges, unstages, restores stash.
100
+ */
101
+ export async function mergeBranch(branch: string, cwd: string): Promise<MergeResult> {
102
+ const didStash = await gitStash(cwd)
103
+
104
+ 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
+
113
+ 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
+ return { ok: true }
119
+ }
120
+
121
+ /**
122
+ * Build the preserved branch name for a team member.
123
+ */
124
+ export function preservedBranchName(teamName: string, memberName: string): string {
125
+ return `ensemble/preserved/${teamName}/${memberName}`
126
+ }