@hueyexe/opencode-ensemble 0.13.2 → 0.14.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 +9 -7
- package/dist/index.js +476 -9
- package/package.json +1 -1
- package/src/index.ts +35 -5
- package/src/state.ts +107 -0
- package/src/system-prompt.ts +7 -0
- package/src/tools/shared.ts +35 -0
- package/src/tools/team-cleanup.ts +422 -2
- package/src/tools/team-spawn.ts +2 -2
- package/src/types.ts +2 -1
|
@@ -1,11 +1,394 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
2
|
+
import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared"
|
|
3
3
|
import type { IsDirtyFn } from "./shared"
|
|
4
4
|
import { spawnFailures } from "./team-spawn"
|
|
5
5
|
import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
|
|
6
6
|
import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
7
7
|
import { log } from "../log"
|
|
8
8
|
|
|
9
|
+
type PurgeApprovalFn = (preview: string) => Promise<void>
|
|
10
|
+
type ListBranchesFn = (teamName: string, cwd: string) => Promise<string[]>
|
|
11
|
+
type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
|
|
12
|
+
|
|
13
|
+
interface PurgeTarget {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
time_updated: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PurgeStats extends PurgeTarget {
|
|
20
|
+
members: number
|
|
21
|
+
tasks: number
|
|
22
|
+
messages: number
|
|
23
|
+
branches: number
|
|
24
|
+
staleResources: number
|
|
25
|
+
staleBranches: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PurgeMemberResource {
|
|
29
|
+
team_id: string
|
|
30
|
+
team_name: string
|
|
31
|
+
member_name: string
|
|
32
|
+
worktree_dir: string | null
|
|
33
|
+
workspace_id: string | null
|
|
34
|
+
worktree_branch: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function listPreservedBranches(teamName: string, cwd: string): Promise<string[]> {
|
|
38
|
+
try {
|
|
39
|
+
const proc = Bun.spawn(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
40
|
+
const out = await new Response(proc.stdout).text()
|
|
41
|
+
const stderr = await new Response(proc.stderr).text()
|
|
42
|
+
const exit = await proc.exited
|
|
43
|
+
if (exit !== 0) throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
|
|
44
|
+
return out.split("\n").map(branch => branch.trim()).filter(Boolean)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
47
|
+
if (message.includes("not a git repository")) return []
|
|
48
|
+
throw new Error(`Failed to list preserved branches for ${teamName}: ${err instanceof Error ? err.message : String(err)}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function branchExists(branch: string, cwd: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
const proc = Bun.spawn(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
55
|
+
const out = await new Response(proc.stdout).text()
|
|
56
|
+
const stderr = await new Response(proc.stderr).text()
|
|
57
|
+
const exit = await proc.exited
|
|
58
|
+
if (exit !== 0) {
|
|
59
|
+
if (stderr.includes("not a git repository")) return false
|
|
60
|
+
throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
|
|
61
|
+
}
|
|
62
|
+
return out.split("\n").map(item => item.trim()).includes(branch)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
65
|
+
if (message.includes("not a git repository")) return false
|
|
66
|
+
throw new Error(`Failed to check stale Ensemble branch ${branch}: ${message}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeBranchName(branch: string): string {
|
|
71
|
+
return branch.trim().replace(/^\*\s*/, "")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
|
|
75
|
+
if (purge.length === 0) throw new Error("Pass at least one archived team name to purge, or ['*'] for all archived teams.")
|
|
76
|
+
if (purge.includes("*") && purge.length !== 1) {
|
|
77
|
+
throw new Error("Wildcard purge cannot be combined with explicit team names. Use purge: ['*'] by itself.")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (purge.includes("*")) {
|
|
81
|
+
return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
|
|
82
|
+
.all() as PurgeTarget[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const uniqueNames = [...new Set(purge)]
|
|
86
|
+
const rows = uniqueNames.map(name => ({
|
|
87
|
+
name,
|
|
88
|
+
team: deps.db.query("SELECT id, name, status, time_updated FROM team WHERE name = ?").get(name) as { id: string; name: string; status: string; time_updated: number } | null,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const missing = rows.filter(row => row.team === null).map(row => row.name)
|
|
92
|
+
if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
|
|
93
|
+
|
|
94
|
+
const active = rows.filter(row => row.team?.status === "active").map(row => row.name)
|
|
95
|
+
if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
|
|
96
|
+
|
|
97
|
+
return rows
|
|
98
|
+
.map(row => row.team!)
|
|
99
|
+
.sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
|
|
103
|
+
const transaction = deps.db.transaction((teams: PurgeTarget[]) => {
|
|
104
|
+
teams.forEach(team => {
|
|
105
|
+
const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(team.id) as { status: string } | null
|
|
106
|
+
if (!row) throw new Error(`Team not found: ${team.name}`)
|
|
107
|
+
if (row.status === "active") throw new Error(`Cannot purge active team: ${team.name}`)
|
|
108
|
+
})
|
|
109
|
+
validatePurgeResources(deps, teams)
|
|
110
|
+
teams.forEach(team => {
|
|
111
|
+
deps.db.run("DELETE FROM team WHERE id = ? AND status = 'archived'", [team.id])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
transaction(targets)
|
|
115
|
+
targets.forEach(team => {
|
|
116
|
+
deps.registry.unregisterTeam(team.id)
|
|
117
|
+
spawnFailures.delete(team.id)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
|
|
122
|
+
return targets.flatMap(target => deps.db.query(
|
|
123
|
+
`SELECT t.name as team_name,
|
|
124
|
+
tm.team_id,
|
|
125
|
+
tm.name as member_name,
|
|
126
|
+
tm.worktree_dir,
|
|
127
|
+
tm.workspace_id,
|
|
128
|
+
tm.worktree_branch
|
|
129
|
+
FROM team_member tm
|
|
130
|
+
JOIN team t ON tm.team_id = t.id
|
|
131
|
+
WHERE tm.team_id = ?`
|
|
132
|
+
).all(target.id) as PurgeMemberResource[])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function preservedBranchPrefix(resource: PurgeMemberResource): string {
|
|
136
|
+
return `ensemble/preserved/${resource.team_name}/`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
|
|
140
|
+
return [
|
|
141
|
+
`ensemble-${resource.team_name}-${resource.member_name}`,
|
|
142
|
+
`opencode/ensemble-${resource.team_name}-${resource.member_name}`,
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isPreservedBranch(resource: PurgeMemberResource): boolean {
|
|
147
|
+
return resource.worktree_branch !== null && resource.worktree_branch.startsWith(preservedBranchPrefix(resource))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
|
|
151
|
+
return resource.worktree_branch !== null && staleEnsembleBranchNames(resource).includes(resource.worktree_branch)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validatePurgeResources(deps: ToolDeps, targets: PurgeTarget[]): void {
|
|
155
|
+
const resources = getPurgeMemberResources(deps, targets)
|
|
156
|
+
const nonPreserved = resources.filter(resource =>
|
|
157
|
+
resource.worktree_branch !== null && !isPreservedBranch(resource) && !isStaleEnsembleBranch(resource)
|
|
158
|
+
)
|
|
159
|
+
if (nonPreserved.length > 0) {
|
|
160
|
+
const details = nonPreserved.map(resource => `${resource.team_name}/${resource.member_name} (${resource.worktree_branch})`).join(", ")
|
|
161
|
+
throw new Error(`Cannot purge archived teams: ${details} has a non-preserved worktree branch or a branch outside its preserved namespace.`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const activeResourceRefs = resources.flatMap(resource => {
|
|
165
|
+
const worktreeRefs = resource.worktree_dir
|
|
166
|
+
? deps.db.query(
|
|
167
|
+
`SELECT t.name as team_name, tm.name as member_name
|
|
168
|
+
FROM team_member tm
|
|
169
|
+
JOIN team t ON tm.team_id = t.id
|
|
170
|
+
WHERE t.status = 'active' AND tm.worktree_dir = ?`
|
|
171
|
+
).all(resource.worktree_dir) as Array<{ team_name: string; member_name: string }>
|
|
172
|
+
: []
|
|
173
|
+
const workspaceRefs = resource.workspace_id
|
|
174
|
+
? deps.db.query(
|
|
175
|
+
`SELECT t.name as team_name, tm.name as member_name
|
|
176
|
+
FROM team_member tm
|
|
177
|
+
JOIN team t ON tm.team_id = t.id
|
|
178
|
+
WHERE t.status = 'active' AND tm.workspace_id = ?`
|
|
179
|
+
).all(resource.workspace_id) as Array<{ team_name: string; member_name: string }>
|
|
180
|
+
: []
|
|
181
|
+
return [
|
|
182
|
+
...worktreeRefs.map(ref => `${resource.team_name}/${resource.member_name} worktree is also referenced by active team ${ref.team_name}/${ref.member_name}`),
|
|
183
|
+
...workspaceRefs.map(ref => `${resource.team_name}/${resource.member_name} workspace is also referenced by active team ${ref.team_name}/${ref.member_name}`),
|
|
184
|
+
]
|
|
185
|
+
})
|
|
186
|
+
if (activeResourceRefs.length > 0) {
|
|
187
|
+
throw new Error(`Cannot purge archived teams: ${[...new Set(activeResourceRefs)].join(", ")}.`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectStaleEnsembleBranches(deps: ToolDeps, targets: PurgeTarget[]): string[] {
|
|
192
|
+
return [...new Set(
|
|
193
|
+
getPurgeMemberResources(deps, targets)
|
|
194
|
+
.filter(isStaleEnsembleBranch)
|
|
195
|
+
.map(resource => resource.worktree_branch!)
|
|
196
|
+
)]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function countStaleResourceRefs(deps: ToolDeps, target: PurgeTarget): number {
|
|
200
|
+
return getPurgeMemberResources(deps, [target]).reduce(
|
|
201
|
+
(count, resource) => count + (resource.worktree_dir ? 1 : 0) + (resource.workspace_id ? 1 : 0),
|
|
202
|
+
0,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function countStaleBranchRefs(deps: ToolDeps, target: PurgeTarget): number {
|
|
207
|
+
return collectStaleEnsembleBranches(deps, [target]).length
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function existingWorktreeDirs(deps: ToolDeps, resources: PurgeMemberResource[]): Promise<Set<string>> {
|
|
211
|
+
if (!resources.some(resource => resource.worktree_dir)) return new Set()
|
|
212
|
+
try {
|
|
213
|
+
const result = await deps.client.worktree.list()
|
|
214
|
+
return new Set((result.data ?? []).map(worktree => worktree.directory))
|
|
215
|
+
} catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
217
|
+
throw new Error(`Cannot purge archived teams: failed to list worktrees before stale resource cleanup: ${message}`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function existingWorkspaceIds(deps: ToolDeps, resources: PurgeMemberResource[]): Promise<Set<string>> {
|
|
222
|
+
if (!resources.some(resource => resource.workspace_id)) return new Set()
|
|
223
|
+
try {
|
|
224
|
+
const result = await deps.client.workspace.list()
|
|
225
|
+
return new Set((result.data ?? []).map(workspace => workspace.id))
|
|
226
|
+
} catch (err) {
|
|
227
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
228
|
+
throw new Error(`Cannot purge archived teams: failed to list workspaces before stale resource cleanup: ${message}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function cleanupStalePurgeResources(deps: ToolDeps, targets: PurgeTarget[], isDirty: IsDirtyFn): Promise<void> {
|
|
233
|
+
const resources = getPurgeMemberResources(deps, targets).filter(resource => resource.worktree_dir || resource.workspace_id)
|
|
234
|
+
if (resources.length === 0) return
|
|
235
|
+
|
|
236
|
+
const worktreeDirs = await existingWorktreeDirs(deps, resources)
|
|
237
|
+
const workspaceIds = await existingWorkspaceIds(deps, resources)
|
|
238
|
+
|
|
239
|
+
for (const resource of resources) {
|
|
240
|
+
if (resource.workspace_id) {
|
|
241
|
+
if (workspaceIds.has(resource.workspace_id)) {
|
|
242
|
+
try {
|
|
243
|
+
await deps.client.workspace.remove({ id: resource.workspace_id })
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
246
|
+
throw new Error(`Failed to remove stale workspace for ${resource.team_name}/${resource.member_name}: ${message}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name])
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (resource.worktree_dir) {
|
|
253
|
+
if (worktreeDirs.has(resource.worktree_dir)) {
|
|
254
|
+
let dirty: boolean
|
|
255
|
+
try {
|
|
256
|
+
dirty = await isDirty(resource.worktree_dir)
|
|
257
|
+
} catch (err) {
|
|
258
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
259
|
+
throw new Error(`Cannot purge archived teams: failed to check archived worktree for uncommitted changes at ${resource.worktree_dir}: ${message}`)
|
|
260
|
+
}
|
|
261
|
+
if (dirty) {
|
|
262
|
+
throw new Error(`Cannot purge archived teams: ${resource.team_name}/${resource.member_name} has uncommitted changes in archived worktree ${resource.worktree_dir}.`)
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: resource.worktree_dir } })
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
268
|
+
throw new Error(`Failed to remove stale worktree for ${resource.team_name}/${resource.member_name}: ${message}`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name])
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function deleteStaleEnsembleBranches(
|
|
277
|
+
branches: string[],
|
|
278
|
+
cwd: string,
|
|
279
|
+
delBranch: DeleteBranchFn,
|
|
280
|
+
exists: BranchExistsFn,
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
for (const branch of branches) {
|
|
283
|
+
if (!await exists(branch, cwd)) continue
|
|
284
|
+
const ok = await delBranch(branch, cwd)
|
|
285
|
+
if (!ok) throw new Error(`Failed to delete stale Ensemble branch: ${branch}`)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validatePurgeTargetsStillArchived(deps: ToolDeps, targets: PurgeTarget[]): void {
|
|
290
|
+
const missing: string[] = []
|
|
291
|
+
const active: string[] = []
|
|
292
|
+
targets.forEach(target => {
|
|
293
|
+
const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(target.id) as { status: string } | null
|
|
294
|
+
if (!row) missing.push(target.name)
|
|
295
|
+
else if (row.status === "active") active.push(target.name)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
|
|
299
|
+
if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function collectPreservedBranches(
|
|
303
|
+
targets: PurgeTarget[],
|
|
304
|
+
cwd: string,
|
|
305
|
+
listBranches: ListBranchesFn,
|
|
306
|
+
): Promise<Map<string, string[]>> {
|
|
307
|
+
const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
|
|
308
|
+
const prefix = `ensemble/preserved/${target.name}/`
|
|
309
|
+
let listed: string[]
|
|
310
|
+
try {
|
|
311
|
+
listed = await listBranches(target.name, cwd)
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
314
|
+
if (message.includes("not a git repository")) listed = []
|
|
315
|
+
else throw new Error(`Failed to list preserved branches for ${target.name}: ${message}`)
|
|
316
|
+
}
|
|
317
|
+
const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
|
|
318
|
+
return [target.id, [...new Set(branches)]]
|
|
319
|
+
}))
|
|
320
|
+
return new Map(entries)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function deletePreservedBranches(
|
|
324
|
+
branchesByTeam: Map<string, string[]>,
|
|
325
|
+
cwd: string,
|
|
326
|
+
delBranch: DeleteBranchFn,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
const branches = [...branchesByTeam.values()].flat()
|
|
329
|
+
for (const branch of branches) {
|
|
330
|
+
const ok = await delBranch(branch, cwd)
|
|
331
|
+
if (!ok) throw new Error(`Failed to delete preserved branch: ${branch}`)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function formatCount(count: number, noun: string, plural = `${noun}s`): string {
|
|
336
|
+
return `${count} ${count === 1 ? noun : plural}`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function buildPurgePreview(deps: ToolDeps, targets: PurgeTarget[], branchesByTeam: Map<string, string[]>): string {
|
|
340
|
+
const rows = targets.map(target => {
|
|
341
|
+
const members = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(target.id) as { c: number }).c
|
|
342
|
+
const tasks = (deps.db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ?").get(target.id) as { c: number }).c
|
|
343
|
+
const messages = (deps.db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ?").get(target.id) as { c: number }).c
|
|
344
|
+
const branches = branchesByTeam.get(target.id)?.length ?? 0
|
|
345
|
+
const staleResources = countStaleResourceRefs(deps, target)
|
|
346
|
+
const staleBranches = countStaleBranchRefs(deps, target)
|
|
347
|
+
return { ...target, members, tasks, messages, branches, staleResources, staleBranches }
|
|
348
|
+
}) satisfies PurgeStats[]
|
|
349
|
+
const totals = rows.reduce(
|
|
350
|
+
(acc, row) => ({
|
|
351
|
+
members: acc.members + row.members,
|
|
352
|
+
tasks: acc.tasks + row.tasks,
|
|
353
|
+
messages: acc.messages + row.messages,
|
|
354
|
+
branches: acc.branches + row.branches,
|
|
355
|
+
staleResources: acc.staleResources + row.staleResources,
|
|
356
|
+
staleBranches: acc.staleBranches + row.staleBranches,
|
|
357
|
+
}),
|
|
358
|
+
{ members: 0, tasks: 0, messages: 0, branches: 0, staleResources: 0, staleBranches: 0 }
|
|
359
|
+
)
|
|
360
|
+
const details = rows.slice(0, 10).map(row =>
|
|
361
|
+
`- ${row.name}: ${formatCount(row.members, "member")}, ${formatCount(row.tasks, "task")}, ${formatCount(row.messages, "message")}, ${formatCount(row.branches, "preserved branch", "preserved branches")}, ${formatCount(row.staleResources, "stale resource")}, ${formatCount(row.staleBranches, "stale branch", "stale branches")}`
|
|
362
|
+
)
|
|
363
|
+
const hidden = rows.length > 10 ? [`...and ${rows.length - 10} more archived team${rows.length - 10 === 1 ? "" : "s"}`] : []
|
|
364
|
+
|
|
365
|
+
return [
|
|
366
|
+
"Permanently delete archived teams?",
|
|
367
|
+
"This will delete archived team records and cascade-delete their members, tasks, and messages.",
|
|
368
|
+
"",
|
|
369
|
+
...details,
|
|
370
|
+
...hidden,
|
|
371
|
+
"",
|
|
372
|
+
`Total: ${formatCount(rows.length, "team")}, ${formatCount(totals.members, "member")}, ${formatCount(totals.tasks, "task")}, ${formatCount(totals.messages, "message")}, ${formatCount(totals.branches, "preserved branch", "preserved branches")}, ${formatCount(totals.staleResources, "stale resource")}, ${formatCount(totals.staleBranches, "stale branch", "stale branches")}`,
|
|
373
|
+
].join("\n")
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildPurgeConfirmationInstructions(preview: string, confirmToken: string): string {
|
|
377
|
+
const approvalLabel = `Approve purge ${confirmToken.slice(0, 8)}`
|
|
378
|
+
const denialLabel = `Deny purge ${confirmToken.slice(0, 8)}`
|
|
379
|
+
return [
|
|
380
|
+
"Purge preview only — no teams were deleted.",
|
|
381
|
+
"",
|
|
382
|
+
preview,
|
|
383
|
+
"",
|
|
384
|
+
"Use the question tool to ask the user whether to permanently delete these archived teams.",
|
|
385
|
+
`The approval option label must be exactly: ${approvalLabel}`,
|
|
386
|
+
`The denial option label must be exactly: ${denialLabel}`,
|
|
387
|
+
`Confirmation token: ${confirmToken}`,
|
|
388
|
+
`Only if the user selects "${approvalLabel}", call team_cleanup again with the same purge value, confirm_purge: true, and confirm_token set to this token.`,
|
|
389
|
+
].join("\n")
|
|
390
|
+
}
|
|
391
|
+
|
|
9
392
|
/**
|
|
10
393
|
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
11
394
|
* Acts as a safety net: merges any remaining unmerged preserved branches
|
|
@@ -13,14 +396,51 @@ import { log } from "../log"
|
|
|
13
396
|
*/
|
|
14
397
|
export async function executeTeamCleanup(
|
|
15
398
|
deps: ToolDeps,
|
|
16
|
-
args: { force: boolean; acknowledge_uncommitted?: boolean },
|
|
399
|
+
args: { force: boolean; acknowledge_uncommitted?: boolean; purge?: string[]; confirm_purge?: boolean; confirm_token?: string },
|
|
17
400
|
sessionId: string,
|
|
18
401
|
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
19
402
|
merge: MergeBranchFn = mergeBranch,
|
|
20
403
|
delBranch: DeleteBranchFn = deleteBranch,
|
|
21
404
|
mergeOnCleanup = true,
|
|
22
405
|
overlapCheck: OverlapCheckFn = getOverlappingFiles,
|
|
406
|
+
_approvePurge?: PurgeApprovalFn,
|
|
407
|
+
_listBranches?: ListBranchesFn,
|
|
408
|
+
_branchExists?: BranchExistsFn,
|
|
23
409
|
): Promise<string> {
|
|
410
|
+
if (args.purge && args.purge.length > 0) {
|
|
411
|
+
requireCanPurgeArchivedTeams(deps, sessionId)
|
|
412
|
+
const targets = resolvePurgeTargets(deps, args.purge)
|
|
413
|
+
if (targets.length === 0) return "No archived teams to purge."
|
|
414
|
+
|
|
415
|
+
validatePurgeTargetsStillArchived(deps, targets)
|
|
416
|
+
validatePurgeResources(deps, targets)
|
|
417
|
+
const branchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches)
|
|
418
|
+
const preview = buildPurgePreview(deps, targets, branchesByTeam)
|
|
419
|
+
if (!args.confirm_purge) {
|
|
420
|
+
const confirmToken = deps.purgeApprovals.create(sessionId, targets.map(target => target.id))
|
|
421
|
+
return buildPurgeConfirmationInstructions(preview, confirmToken)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!args.confirm_token) {
|
|
425
|
+
throw new Error("A purge confirmation token is required. First call team_cleanup without confirm_purge, then use the question tool before confirming.")
|
|
426
|
+
}
|
|
427
|
+
deps.purgeApprovals.consume(sessionId, args.confirm_token, targets.map(target => target.id))
|
|
428
|
+
|
|
429
|
+
validatePurgeTargetsStillArchived(deps, targets)
|
|
430
|
+
validatePurgeResources(deps, targets)
|
|
431
|
+
await cleanupStalePurgeResources(deps, targets, isDirty)
|
|
432
|
+
|
|
433
|
+
validatePurgeResources(deps, targets)
|
|
434
|
+
await deleteStaleEnsembleBranches(collectStaleEnsembleBranches(deps, targets), deps.directory, delBranch, _branchExists ?? branchExists)
|
|
435
|
+
const finalBranchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches)
|
|
436
|
+
await deletePreservedBranches(finalBranchesByTeam, deps.directory, delBranch)
|
|
437
|
+
|
|
438
|
+
deleteArchivedTeams(deps, targets)
|
|
439
|
+
|
|
440
|
+
const noun = targets.length === 1 ? "archived team" : "archived teams"
|
|
441
|
+
return `Permanently deleted ${targets.length} ${noun}: ${targets.map(target => target.name).join(", ")}.`
|
|
442
|
+
}
|
|
443
|
+
|
|
24
444
|
const teamInfo = requireLead(deps, sessionId)
|
|
25
445
|
|
|
26
446
|
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -85,7 +85,8 @@ export async function executeTeamSpawn(
|
|
|
85
85
|
.get(teamInfo.teamId, args.name)
|
|
86
86
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
87
87
|
|
|
88
|
-
const
|
|
88
|
+
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
89
|
+
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
|
|
89
90
|
const usePlanApproval = args.plan_approval === true
|
|
90
91
|
|
|
91
92
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
|
|
@@ -143,7 +144,6 @@ export async function executeTeamSpawn(
|
|
|
143
144
|
// Permission rules on session.create are the hard gate (server-enforced).
|
|
144
145
|
// For read-only agents, deny write tools and explicitly allow team tools.
|
|
145
146
|
// For all agents with worktrees, allowlist the worktree path for edit/bash.
|
|
146
|
-
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
147
147
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
|
|
148
148
|
const permission: PermissionRule[] = []
|
|
149
149
|
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
2
|
+
import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
|
|
3
3
|
import type { EnsembleConfig } from "./config"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -10,6 +10,7 @@ export interface ToolDeps {
|
|
|
10
10
|
db: Database
|
|
11
11
|
registry: MemberRegistry
|
|
12
12
|
tracker: DescendantTracker
|
|
13
|
+
purgeApprovals: PendingPurgeApprovals
|
|
13
14
|
/** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */
|
|
14
15
|
client: PluginClient
|
|
15
16
|
/** The project root directory — used for reading AGENTS.md and other project files. */
|