@akira-tl/forgerelay 0.8.1 → 0.8.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.2] - 2026-08-31
8
+
9
+ ### Changed
10
+
11
+ - Managed-worktree Workspace close now preserves the same identity for later reopen with fresh backing; explicit delete safely finalizes active work before removing ForgeRelay-owned state.
12
+ - Composite Workspace close now preserves identity and member topology for later reopen; explicit delete dissolves only the Composite relationship and leaves member Workspaces untouched.
13
+
7
14
  ## [0.8.1] - 2026-08-30
8
15
 
9
16
  ### Added
package/README.md CHANGED
@@ -136,8 +136,10 @@ ForgeRelay does not merge member filesystems, Git state, Hooks, Skills, processe
136
136
  or audit facts, and it never infers a member from the tool type or purpose text.
137
137
  The Composite Activity Panel presents member operations in one Host Turn while the
138
138
  actual facts remain owned by the member Workspace. `close_workspace` on a Composite
139
- Workspace means **dissolve**: the Composite identity and membership are removed, but
140
- member Workspaces, worktrees, running jobs, files, and relay routes are preserved.
139
+ Workspace now preserves the Composite identity and member topology as `closed`; a
140
+ later `open_workspace` restores the same `cws_...` identity. `action="delete"` is the
141
+ explicit dissolve operation. Neither close nor delete closes member Workspaces,
142
+ finalizes their worktrees, stops their jobs, changes their files, or removes relay routes.
141
143
 
142
144
  ### Progressive MCP context
143
145
 
@@ -12,7 +12,7 @@ export class CompositeActivityCoordinator {
12
12
  return this.composites.has(workspaceId);
13
13
  }
14
14
  beginPanel(workspaceId, conversationScopeId) {
15
- this.composites.open(workspaceId);
15
+ this.composites.touchActive(workspaceId);
16
16
  const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
17
17
  this.turns.set(snapshot.turnId, {
18
18
  compositeWorkspaceId: workspaceId,
@@ -13,16 +13,20 @@ export class CompositeWorkspaceRegistry {
13
13
  has(workspaceId) {
14
14
  return this.records.has(workspaceId);
15
15
  }
16
+ isActive(workspaceId) {
17
+ return this.records.get(workspaceId)?.status === "active";
18
+ }
16
19
  create(name) {
17
20
  const normalized = normalizeName(name);
18
21
  const existing = [...this.records.values()].find((record) => record.name === normalized);
19
22
  if (existing)
20
- return this.touch(existing.id);
23
+ return this.open(existing.id);
21
24
  const now = new Date().toISOString();
22
25
  const record = {
23
26
  id: `cws_${randomBytes(5).toString("hex")}`,
24
27
  kind: "composite",
25
28
  name: normalized,
29
+ status: "active",
26
30
  members: [],
27
31
  createdAt: now,
28
32
  lastUsedAt: now,
@@ -38,7 +42,17 @@ export class CompositeWorkspaceRegistry {
38
42
  return cloneRecord(record);
39
43
  }
40
44
  open(workspaceId) {
41
- return this.touch(workspaceId);
45
+ const record = this.requireRecord(workspaceId);
46
+ record.status = "active";
47
+ return this.touchRecord(record);
48
+ }
49
+ close(workspaceId) {
50
+ const record = this.requireActive(workspaceId);
51
+ record.status = "closed";
52
+ return this.touchRecord(record);
53
+ }
54
+ touchActive(workspaceId) {
55
+ return this.touchRecord(this.requireActive(workspaceId));
42
56
  }
43
57
  list() {
44
58
  return [...this.records.values()]
@@ -46,7 +60,7 @@ export class CompositeWorkspaceRegistry {
46
60
  .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
47
61
  }
48
62
  addMember(workspaceId, input) {
49
- const record = this.requireRecord(workspaceId);
63
+ const record = this.requireActive(workspaceId);
50
64
  const name = normalizeMemberName(input.name);
51
65
  const purpose = input.purpose.trim();
52
66
  if (!purpose)
@@ -64,7 +78,7 @@ export class CompositeWorkspaceRegistry {
64
78
  return cloneRecord(record);
65
79
  }
66
80
  updateMember(workspaceId, memberName, input) {
67
- const record = this.requireRecord(workspaceId);
81
+ const record = this.requireActive(workspaceId);
68
82
  const currentName = normalizeMemberName(memberName);
69
83
  const index = record.members.findIndex((member) => member.name === currentName);
70
84
  if (index < 0)
@@ -94,7 +108,7 @@ export class CompositeWorkspaceRegistry {
94
108
  return cloneRecord(record);
95
109
  }
96
110
  removeMember(workspaceId, memberName) {
97
- const record = this.requireRecord(workspaceId);
111
+ const record = this.requireActive(workspaceId);
98
112
  const name = normalizeMemberName(memberName);
99
113
  const index = record.members.findIndex((member) => member.name === name);
100
114
  if (index < 0)
@@ -105,7 +119,7 @@ export class CompositeWorkspaceRegistry {
105
119
  return cloneRecord(record);
106
120
  }
107
121
  member(workspaceId, memberName) {
108
- const record = this.requireRecord(workspaceId);
122
+ const record = this.requireActive(workspaceId);
109
123
  const name = normalizeMemberName(memberName);
110
124
  const member = record.members.find((entry) => entry.name === name);
111
125
  if (!member)
@@ -118,12 +132,18 @@ export class CompositeWorkspaceRegistry {
118
132
  this.persist();
119
133
  return cloneRecord(record);
120
134
  }
121
- touch(workspaceId) {
122
- const record = this.requireRecord(workspaceId);
135
+ touchRecord(record) {
123
136
  record.lastUsedAt = new Date().toISOString();
124
137
  this.persist();
125
138
  return cloneRecord(record);
126
139
  }
140
+ requireActive(workspaceId) {
141
+ const record = this.requireRecord(workspaceId);
142
+ if (record.status !== "active") {
143
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
144
+ }
145
+ return record;
146
+ }
127
147
  requireRecord(workspaceId) {
128
148
  const record = this.records.get(workspaceId);
129
149
  if (!record)
@@ -140,7 +160,7 @@ export class CompositeWorkspaceRegistry {
140
160
  return;
141
161
  throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
142
162
  }
143
- if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
163
+ if ((parsed?.version !== 1 && parsed?.version !== 2) || !Array.isArray(parsed.workspaces)) {
144
164
  throw new Error("Composite Workspace state has an unsupported format.");
145
165
  }
146
166
  for (const record of parsed.workspaces) {
@@ -148,6 +168,7 @@ export class CompositeWorkspaceRegistry {
148
168
  continue;
149
169
  this.records.set(record.id, {
150
170
  ...record,
171
+ status: record.status === "closed" ? "closed" : "active",
151
172
  members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
152
173
  });
153
174
  }
@@ -155,7 +176,7 @@ export class CompositeWorkspaceRegistry {
155
176
  persist() {
156
177
  mkdirSync(this.stateDir, { recursive: true });
157
178
  const state = {
158
- version: 1,
179
+ version: 2,
159
180
  workspaces: [...this.records.values()].map(cloneRecord),
160
181
  };
161
182
  const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
@@ -57,6 +57,28 @@ export async function createManagedWorktree(input) {
57
57
  managed: true,
58
58
  };
59
59
  }
60
+ export async function discardFreshManagedWorktree(input) {
61
+ const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
62
+ const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
63
+ const worktreeBranch = await currentBranch(worktreePath);
64
+ if (worktreeBranch !== input.worktree.branch) {
65
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Cannot roll back reopened worktree because it is on branch ${JSON.stringify(worktreeBranch)} instead of ${JSON.stringify(input.worktree.branch)}.`);
66
+ }
67
+ if ((await git(["status", "--porcelain=v1"], worktreePath)).trim().length > 0) {
68
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because it acquired uncommitted changes during reopen.");
69
+ }
70
+ const worktreeHead = (await git(["rev-parse", "HEAD"], worktreePath)).trim();
71
+ if (worktreeHead !== input.worktree.baseSha) {
72
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because its branch advanced during reopen.");
73
+ }
74
+ try {
75
+ await git(["worktree", "remove", worktreePath], sourceRoot);
76
+ await git(["branch", "-D", input.worktree.branch], sourceRoot);
77
+ }
78
+ catch (error) {
79
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Git failed to remove the temporary managed worktree created for a failed reopen. ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
60
82
  export async function closeManagedWorktree(input) {
61
83
  const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
62
84
  const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
@@ -38,7 +38,7 @@ function capabilityContractInstructions(config) {
38
38
  const staleWorkspacePolicy = config.toolMode === "codex"
39
39
  ? ""
40
40
  : ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
41
- const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} defaults to closing checkout Workspaces for later reopen; action=delete removes only ForgeRelay-owned checkout state, never project files. Managed close finalizes the worktree and requires commitMessage.`;
41
+ const workspaceLifecycle = `Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} preserves Workspace identity. Managed close finalizes backing and needs commitMessage. Composite close preserves members; delete removes only Composite state. Active worktree delete still finalizes safely; checkout files are never deleted.`;
42
42
  const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
43
43
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
44
44
  const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
package/dist/server.js CHANGED
@@ -2078,6 +2078,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2078
2078
  memberAction: z.enum(["add", "update", "remove"]).optional(),
2079
2079
  kind: z.enum(["workspace", "composite"]).optional(),
2080
2080
  name: z.string().optional(),
2081
+ status: z.string().optional(),
2082
+ state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
2081
2083
  members: z.array(z.object({
2082
2084
  name: z.string(),
2083
2085
  purpose: z.string(),
@@ -2134,6 +2136,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2134
2136
  workspaceId: z.string(),
2135
2137
  kind: z.literal("composite"),
2136
2138
  name: z.string(),
2139
+ status: z.enum(["active", "closed"]),
2140
+ state: z.enum(["active", "closed"]),
2137
2141
  members: z.array(z.object({
2138
2142
  name: z.string(),
2139
2143
  purpose: z.string(),
@@ -2161,6 +2165,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2161
2165
  if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
2162
2166
  throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
2163
2167
  }
2168
+ if (!compositeWorkspaces.isActive(workspaceId)) {
2169
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before changing members.`);
2170
+ }
2164
2171
  if (!memberAction || !member) {
2165
2172
  throw new Error("open_workspace action=member requires memberAction and member.");
2166
2173
  }
@@ -2294,25 +2301,30 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2294
2301
  newWorkspace !== undefined || context !== undefined) {
2295
2302
  throw new Error("open_workspace action=list does not accept path, relay, name, memberName, baseRef, newWorktree, newWorkspace, or context. Use kind/root/workspaceId/mode/status/state/staleOnly for inventory filters.");
2296
2303
  }
2304
+ const compositeInventory = () => compositeWorkspaces.list()
2305
+ .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2306
+ .filter((entry) => status === undefined || entry.status === status)
2307
+ .filter((entry) => state === undefined || entry.status === state)
2308
+ .map((entry) => ({
2309
+ workspaceId: entry.id,
2310
+ kind: entry.kind,
2311
+ name: entry.name,
2312
+ status: entry.status,
2313
+ state: entry.status,
2314
+ members: entry.members,
2315
+ createdAt: entry.createdAt,
2316
+ lastUsedAt: entry.lastUsedAt,
2317
+ }));
2297
2318
  if (kind === "composite") {
2298
- if (root !== undefined || mode !== undefined || status !== undefined || state !== undefined ||
2299
- staleOnly !== undefined || offset !== undefined || limit !== undefined) {
2300
- throw new Error("Composite Workspace inventory does not accept root/mode/status/state/staleOnly/offset/limit filters; use workspaceId when selecting one Composite Workspace.");
2319
+ if (root !== undefined || mode !== undefined || staleOnly !== undefined ||
2320
+ offset !== undefined || limit !== undefined) {
2321
+ throw new Error("Composite Workspace inventory does not accept root/mode/staleOnly/offset/limit filters; use workspaceId/status/state when selecting Composite Workspaces.");
2301
2322
  }
2302
- const composites = compositeWorkspaces.list()
2303
- .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2304
- .map((entry) => ({
2305
- workspaceId: entry.id,
2306
- kind: entry.kind,
2307
- name: entry.name,
2308
- members: entry.members,
2309
- createdAt: entry.createdAt,
2310
- lastUsedAt: entry.lastUsedAt,
2311
- }));
2312
- const instruction = "Resume a Composite Workspace with open_workspace(action=\"open\", workspaceId=...). Use close_workspace only when the user chooses to dissolve it.";
2323
+ const composites = compositeInventory();
2324
+ const instruction = "Open a Composite Workspace by workspaceId to resume or reopen it. close_workspace preserves its identity; action=delete permanently dissolves only Composite-owned state.";
2313
2325
  const result = [
2314
2326
  `Composite Workspace inventory: ${composites.length} matching record${composites.length === 1 ? "" : "s"}.`,
2315
- ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2327
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] state=${entry.state} members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2316
2328
  instruction,
2317
2329
  ].join("\n");
2318
2330
  return {
@@ -2325,20 +2337,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2325
2337
  };
2326
2338
  }
2327
2339
  const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
2328
- const composites = kind === "workspace"
2340
+ const composites = kind === "workspace" || root !== undefined || mode !== undefined || staleOnly
2329
2341
  ? []
2330
- : compositeWorkspaces.list().map((entry) => ({
2331
- workspaceId: entry.id,
2332
- kind: entry.kind,
2333
- name: entry.name,
2334
- members: entry.members,
2335
- createdAt: entry.createdAt,
2336
- lastUsedAt: entry.lastUsedAt,
2337
- }));
2342
+ : compositeInventory();
2338
2343
  const nextOffset = inventory.page.offset + inventory.page.limit;
2339
2344
  const instruction = [
2340
2345
  "Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
2341
- "Use close_workspace only after the user chooses cleanup or Composite dissolution; never close inventory entries automatically.",
2346
+ "Use close_workspace only after the user chooses cleanup; Composite close preserves identity, while action=delete dissolves only Composite-owned state. Never close inventory entries automatically.",
2342
2347
  inventory.page.hasMore
2343
2348
  ? `More matching workspaces are available; continue with offset=${nextOffset}.`
2344
2349
  : undefined,
@@ -2356,7 +2361,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2356
2361
  `root=${entry.root}`,
2357
2362
  `last-used=${entry.lastUsedAt}`,
2358
2363
  ].filter(Boolean).join(" ")),
2359
- ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite members=${entry.members.length}`),
2364
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite state=${entry.state} members=${entry.members.length}`),
2360
2365
  instruction,
2361
2366
  ].join("\n");
2362
2367
  logToolCall(config, {
@@ -2406,7 +2411,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2406
2411
  composite.members.length > 0
2407
2412
  ? "Before first work on a member, reopen this Composite Workspace with memberName=<member> and context=auto to receive that member's project bootstrap without creating an implicit current member."
2408
2413
  : undefined,
2409
- "Use close_workspace only when the user chooses to dissolve this Composite Workspace; dissolution does not close or clean up member Workspaces.",
2414
+ "close_workspace preserves this Composite identity for later reopen. Use action=delete only when the user explicitly wants to dissolve the Composite relationship; neither operation closes or cleans up member Workspaces.",
2410
2415
  ].join("\n\n");
2411
2416
  const response = {
2412
2417
  content: [textBlock(instruction)],
@@ -2419,7 +2424,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2419
2424
  path: composite.name,
2420
2425
  members: composite.members,
2421
2426
  instruction,
2422
- summary: { members: composite.members.length },
2427
+ summary: { members: composite.members.length, status: composite.status },
2423
2428
  },
2424
2429
  },
2425
2430
  structuredContent: {
@@ -2427,6 +2432,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2427
2432
  workspaceId: composite.id,
2428
2433
  kind: "composite",
2429
2434
  name: composite.name,
2435
+ status: composite.status,
2436
+ state: composite.status,
2430
2437
  members: composite.members,
2431
2438
  ...(memberContext ? { memberContext } : {}),
2432
2439
  instruction,
@@ -2921,13 +2928,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2921
2928
  });
2922
2929
  registerAppTool(server, toolNames.closeWorkspace, {
2923
2930
  title: "Close workspace",
2924
- description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout identity for later reopen. action=delete permanently removes ForgeRelay-owned checkout state but never project files. Managed-worktree-backed Workspaces still use the safe finalize lifecycle and require commitMessage; Composite and relayed delete semantics are not available in this stage.",
2931
+ description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout, managed-worktree, and Composite identity for later reopen. action=delete permanently removes ForgeRelay-owned state. Managed-worktree-backed Workspaces still finalize safely when active and require commitMessage. Composite delete dissolves only Composite-owned state and never closes member Workspaces. Checkout project files are never deleted; relayed delete remains unavailable.",
2925
2932
  inputSchema: {
2926
2933
  workspaceId: z.string().describe("Workspace identifier to close or delete."),
2927
2934
  action: z
2928
2935
  .enum(["close", "delete"])
2929
2936
  .optional()
2930
- .describe("Defaults to close. close preserves checkout identity for later reopen; delete permanently removes ForgeRelay-owned checkout state without deleting project files."),
2937
+ .describe("Defaults to close. close preserves checkout identity, managed-worktree identity, and Composite identity for later reopen; delete removes ForgeRelay-owned state. Composite delete dissolves only the Composite relationship. Active managed worktrees still require safe finalization and commitMessage; checkout project files are never deleted."),
2931
2938
  commitMessage: z
2932
2939
  .string()
2933
2940
  .min(1)
@@ -2945,6 +2952,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2945
2952
  purpose: z.string(),
2946
2953
  workspaceId: z.string(),
2947
2954
  })).optional(),
2955
+ status: z.enum(["active", "closed"]).optional(),
2948
2956
  dissolved: z.boolean().optional(),
2949
2957
  sourceRoot: z.string().optional(),
2950
2958
  branch: z.string().optional(),
@@ -2958,21 +2966,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2958
2966
  annotations: WRITE_TOOL_ANNOTATIONS,
2959
2967
  }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
2960
2968
  if (compositeWorkspaces.has(workspaceId)) {
2961
- if (action === "delete") {
2962
- throw new Error("close_workspace action=delete is not available for Composite Workspaces until the Composite persistent lifecycle stage.");
2963
- }
2964
2969
  if (commitMessage !== undefined) {
2965
- throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
2970
+ throw new Error("close_workspace commitMessage is not valid for a Composite Workspace.");
2966
2971
  }
2967
- const composite = compositeWorkspaces.dissolve(workspaceId);
2972
+ const composite = action === "delete"
2973
+ ? compositeWorkspaces.dissolve(workspaceId)
2974
+ : compositeWorkspaces.close(workspaceId);
2968
2975
  compositeActivity.forgetComposite(workspaceId);
2969
2976
  workspacePanelStates.delete(workspaceId);
2970
2977
  const result = [
2971
- `Dissolved Composite Workspace ${composite.name} (${workspaceId}).`,
2978
+ action === "delete"
2979
+ ? `Deleted Composite Workspace ${composite.name} (${workspaceId}); its Composite relationship and ForgeRelay-owned Composite state were dissolved.`
2980
+ : `Closed Composite Workspace ${composite.name} (${workspaceId}); its identity and member topology were preserved for later reopen.`,
2972
2981
  composite.members.length > 0
2973
2982
  ? `Preserved member Workspaces: ${composite.members.map((member) => `${member.name} [${member.workspaceId}]`).join(", ")}.`
2974
2983
  : "The Composite Workspace had no members.",
2975
- "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed or cleaned up.",
2984
+ "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed, finalized, deleted, or otherwise mutated.",
2976
2985
  ].join("\n");
2977
2986
  return {
2978
2987
  content: [textBlock(result)],
@@ -2980,22 +2989,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2980
2989
  tool: toolNames.closeWorkspace,
2981
2990
  card: {
2982
2991
  workspaceId,
2983
- action: "close",
2992
+ action,
2984
2993
  kind: "composite",
2985
2994
  name: composite.name,
2986
2995
  members: composite.members,
2987
- dissolved: true,
2996
+ ...(action === "close" ? { status: "closed" } : {}),
2997
+ dissolved: action === "delete",
2988
2998
  payload: { content: [textBlock(result)] },
2989
2999
  },
2990
3000
  },
2991
3001
  structuredContent: {
2992
3002
  result,
2993
3003
  workspaceId,
2994
- action: "close",
3004
+ action,
2995
3005
  kind: "composite",
2996
3006
  name: composite.name,
2997
3007
  members: composite.members,
2998
- dissolved: true,
3008
+ ...(action === "close" ? { status: "closed" } : {}),
3009
+ dissolved: action === "delete",
2999
3010
  },
3000
3011
  };
3001
3012
  }
@@ -3007,14 +3018,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3007
3018
  workspacePanelStates.delete(workspaceId);
3008
3019
  return response;
3009
3020
  }
3010
- if (action === "delete") {
3021
+ const session = workspaces.getWorkspaceSession(workspaceId);
3022
+ if (action === "delete" && session.mode === "checkout") {
3011
3023
  if (commitMessage !== undefined) {
3012
3024
  throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
3013
3025
  }
3014
- const session = workspaces.getWorkspaceSession(workspaceId);
3015
- if (session.mode !== "checkout") {
3016
- throw new Error("close_workspace action=delete is not available for managed-worktree-backed Workspaces until their persistent lifecycle stage.");
3017
- }
3018
3026
  if (processSessions.activeWorkspaceIds().has(session.id)) {
3019
3027
  throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
3020
3028
  }
@@ -3055,12 +3063,58 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3055
3063
  workspacePanelStates.delete(session.id);
3056
3064
  return response;
3057
3065
  }
3058
- const workspace = workspaces.getWorkspace(workspaceId);
3066
+ if (action === "delete" && session.mode === "worktree" && session.status === "closed") {
3067
+ if (commitMessage !== undefined) {
3068
+ throw new Error("close_workspace commitMessage is not needed when deleting an already-closed managed-worktree Workspace.");
3069
+ }
3070
+ const hookRoot = session.sourceRoot ?? session.root;
3071
+ const response = await runToolWithHooks(hooks, {
3072
+ signal: extra.signal,
3073
+ tool: toolNames.closeWorkspace,
3074
+ invocation: {
3075
+ workspaceId: session.id,
3076
+ workspaceRoot: hookRoot,
3077
+ workspaceMode: session.mode,
3078
+ sourceRoot: session.sourceRoot,
3079
+ },
3080
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3081
+ operation: async () => {
3082
+ workspaces.deleteWorkspace(session.id);
3083
+ await reviewCheckpoints.releaseWorkspace(session.id);
3084
+ const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
3085
+ return {
3086
+ content: [textBlock(result)],
3087
+ _meta: {
3088
+ tool: toolNames.closeWorkspace,
3089
+ card: {
3090
+ workspaceId: session.id,
3091
+ action: "delete",
3092
+ mode: "worktree",
3093
+ sourceRoot: session.sourceRoot,
3094
+ targetBranch: session.targetBranch,
3095
+ payload: { content: [textBlock(result)] },
3096
+ },
3097
+ },
3098
+ structuredContent: {
3099
+ result,
3100
+ workspaceId: session.id,
3101
+ action: "delete",
3102
+ mode: "worktree",
3103
+ sourceRoot: session.sourceRoot,
3104
+ targetBranch: session.targetBranch,
3105
+ },
3106
+ };
3107
+ },
3108
+ });
3109
+ workspacePanelStates.delete(session.id);
3110
+ return response;
3111
+ }
3112
+ const workspace = workspaces.getWorkspace(session.id);
3059
3113
  const response = await runToolWithHooks(hooks, {
3060
3114
  signal: extra.signal,
3061
3115
  tool: toolNames.closeWorkspace,
3062
3116
  invocation: workspaceHookInvocation(workspace),
3063
- payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
3117
+ payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
3064
3118
  afterCwd: (response) => "sourceRoot" in response.structuredContent &&
3065
3119
  typeof response.structuredContent.sourceRoot === "string"
3066
3120
  ? response.structuredContent.sourceRoot
@@ -3068,7 +3122,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3068
3122
  operation: async () => {
3069
3123
  if (workspace.mode === "worktree") {
3070
3124
  if (!commitMessage) {
3071
- throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
3125
+ throw new Error(`Managed-worktree-backed Workspace ${workspace.id} requires commitMessage when ${action === "delete" ? "deleting active work" : "closing"}.`);
3072
3126
  }
3073
3127
  const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
3074
3128
  const busyWorkspaceIds = physicalWorkspaceIds
@@ -3080,14 +3134,19 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3080
3134
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
3081
3135
  let closed;
3082
3136
  try {
3083
- closed = await workspaces.closeWorktree(workspaceId, commitMessage);
3137
+ closed = await workspaces.closeWorktree(workspace.id, commitMessage);
3084
3138
  }
3085
3139
  finally {
3086
3140
  codeIntelligence.restoreWorkspaceRoot(retirement.root);
3087
3141
  }
3088
3142
  await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
3143
+ if (action === "delete") {
3144
+ workspaces.deleteWorkspace(workspace.id);
3145
+ }
3089
3146
  const result = [
3090
- `Closed managed-worktree-backed workspace ${workspaceId}.`,
3147
+ action === "delete"
3148
+ ? `Safely finalized and deleted managed-worktree Workspace ${workspace.id}.`
3149
+ : `Closed managed-worktree-backed Workspace ${workspace.id}; its identity was preserved for later reopen.`,
3091
3150
  `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
3092
3151
  `Source checkout: ${closed.sourceRoot}`,
3093
3152
  `Commit: ${closed.commitSha}`,
@@ -3107,8 +3166,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3107
3166
  _meta: {
3108
3167
  tool: toolNames.closeWorkspace,
3109
3168
  card: {
3110
- workspaceId,
3111
- action: "close",
3169
+ workspaceId: workspace.id,
3170
+ action,
3112
3171
  mode: "worktree",
3113
3172
  sourceRoot: closed.sourceRoot,
3114
3173
  branch: closed.branch,
@@ -3122,8 +3181,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3122
3181
  },
3123
3182
  structuredContent: {
3124
3183
  result,
3125
- workspaceId,
3126
- action: "close",
3184
+ workspaceId: workspace.id,
3185
+ action,
3127
3186
  mode: "worktree",
3128
3187
  sourceRoot: closed.sourceRoot,
3129
3188
  branch: closed.branch,
@@ -90,6 +90,31 @@ export class SqliteWorkspaceStore {
90
90
  .where(eq(workspaceSessions.id, sessionId))
91
91
  .run();
92
92
  }
93
+ replaceWorktreeBacking(input) {
94
+ const sessionId = this.resolveSessionId(input.id);
95
+ if (!sessionId)
96
+ throw new Error(`Unknown workspace session: ${input.id}`);
97
+ this.pendingSessionTouches.delete(sessionId);
98
+ const row = this.database.db
99
+ .update(workspaceSessions)
100
+ .set({
101
+ root: input.root,
102
+ status: "active",
103
+ sourceRoot: input.sourceRoot,
104
+ baseRef: input.baseRef,
105
+ baseSha: input.baseSha,
106
+ branch: input.branch,
107
+ targetBranch: input.targetBranch,
108
+ managed: "true",
109
+ lastUsedAt: this.now().toISOString(),
110
+ })
111
+ .where(eq(workspaceSessions.id, sessionId))
112
+ .returning()
113
+ .get();
114
+ if (!row)
115
+ throw new Error(`Unknown workspace session: ${input.id}`);
116
+ return rowToWorkspaceSession(row);
117
+ }
93
118
  listSessions(input = {}) {
94
119
  const conditions = [
95
120
  input.status ? eq(workspaceSessions.status, input.status) : undefined,
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { loadCapabilityGuides, markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "./capabilities.js";
7
7
  import { HookRunner } from "./hooks.js";
8
- import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
8
+ import { closeManagedWorktree, createManagedWorktree, discardFreshManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
9
9
  import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
10
10
  import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./skills.js";
11
11
  import { loadSubagentProfiles, } from "./subagents/profiles.js";
@@ -152,8 +152,11 @@ export class WorkspaceRegistry {
152
152
  };
153
153
  }
154
154
  async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
155
- const workspace = this.workspaceForOpen(workspaceId);
156
- const context = await this.reusedWorkspaceContext(workspace);
155
+ const session = this.store?.getSession(workspaceId);
156
+ const context = session?.status === "closed" && session.mode === "worktree"
157
+ ? await this.reopenClosedManagedWorktreeContext(session)
158
+ : await this.reusedWorkspaceContext(await this.workspaceForOpen(workspaceId));
159
+ const workspace = context.workspace;
157
160
  if (!conversationScopeId || !this.store) {
158
161
  return {
159
162
  ...context,
@@ -222,8 +225,8 @@ export class WorkspaceRegistry {
222
225
  }
223
226
  deleteWorkspace(workspaceId) {
224
227
  const session = this.getWorkspaceSession(workspaceId);
225
- if (session.mode !== "checkout") {
226
- throw new Error(`Workspace ${session.id} is not a checkout Workspace. Delete semantics for ${session.mode} Workspaces are handled by their own lifecycle.`);
228
+ if (session.mode === "worktree" && session.status === "active") {
229
+ throw new Error(`Workspace ${session.id} is an active managed-worktree Workspace. Finalize it safely before deleting its persistent identity.`);
227
230
  }
228
231
  this.deleteConversationBindingsForWorkspace(session.id);
229
232
  this.store?.deleteSession(session.id);
@@ -327,6 +330,7 @@ export class WorkspaceRegistry {
327
330
  },
328
331
  }));
329
332
  for (const aliasedWorkspaceId of aliasedWorkspaceIds) {
333
+ this.deleteConversationBindingsForWorkspace(aliasedWorkspaceId);
330
334
  this.store?.setSessionStatus(aliasedWorkspaceId, "closed");
331
335
  this.workspaces.delete(aliasedWorkspaceId);
332
336
  }
@@ -386,10 +390,8 @@ export class WorkspaceRegistry {
386
390
  if (boundContext)
387
391
  return boundContext;
388
392
  const context = await this.openOnce(targetKey, async () => {
389
- const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
390
- if (!reusableWorkspace)
391
- return this.openWorktreeWorkspace(path, input.baseRef);
392
- return this.reusedWorkspaceContext(reusableWorkspace);
393
+ const reusableContext = await this.findReusableWorktreeContextBySource(sourceKey, resolvedBase.targetBranch);
394
+ return reusableContext ?? this.openWorktreeWorkspace(path, input.baseRef);
393
395
  });
394
396
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
395
397
  }
@@ -576,16 +578,30 @@ export class WorkspaceRegistry {
576
578
  }
577
579
  return undefined;
578
580
  }
579
- async findReusableWorktreeBySource(sourceKey, targetBranch) {
580
- for (const session of this.activeSessions("worktree")) {
581
+ async findReusableWorktreeContextBySource(sourceKey, targetBranch) {
582
+ const sessions = this.store
583
+ ? this.store.listSessions({ mode: "worktree" })
584
+ : this.activeSessions("worktree");
585
+ const closedMatches = [];
586
+ for (const session of sessions) {
581
587
  if (!session.sourceRoot || session.targetBranch !== targetBranch)
582
588
  continue;
583
589
  if (await canonicalPath(session.sourceRoot) !== sourceKey)
584
590
  continue;
591
+ if (session.status === "closed") {
592
+ if (session.managed)
593
+ closedMatches.push(session);
594
+ continue;
595
+ }
596
+ if (session.status !== "active")
597
+ continue;
585
598
  const root = await this.validSessionRoot(session);
586
599
  if (!root)
587
600
  continue;
588
- return this.workspaceFromSession(session, false);
601
+ return this.reusedWorkspaceContext(this.workspaceFromSession(session, false));
602
+ }
603
+ if (closedMatches.length === 1) {
604
+ return this.reopenClosedManagedWorktreeContext(closedMatches[0]);
589
605
  }
590
606
  return undefined;
591
607
  }
@@ -654,7 +670,7 @@ export class WorkspaceRegistry {
654
670
  includeBootstrapContext: true,
655
671
  };
656
672
  }
657
- workspaceForOpen(workspaceId) {
673
+ async workspaceForOpen(workspaceId) {
658
674
  const session = this.store?.getSession(workspaceId);
659
675
  if (session?.status === "closed" && session.mode === "checkout") {
660
676
  this.store?.setSessionStatus(session.id, "active");
@@ -666,6 +682,72 @@ export class WorkspaceRegistry {
666
682
  }
667
683
  return this.getWorkspace(workspaceId);
668
684
  }
685
+ async reopenClosedManagedWorktreeContext(session) {
686
+ const operationKey = JSON.stringify(["worktree-reopen", session.id]);
687
+ return this.openOnce(operationKey, async () => {
688
+ const current = this.store?.getSession(session.id);
689
+ if (!current) {
690
+ throw new Error(`Unknown workspaceId: ${session.id}. Call open_workspace first.`);
691
+ }
692
+ if (current.status === "active") {
693
+ return this.reusedWorkspaceContext(this.getWorkspace(current.id));
694
+ }
695
+ if (current.status !== "closed" || current.mode !== "worktree") {
696
+ throw new Error(`Workspace ${current.id} is not a closed managed-worktree Workspace.`);
697
+ }
698
+ return this.reopenClosedManagedWorktreeContextUnlocked(current);
699
+ });
700
+ }
701
+ async reopenClosedManagedWorktreeContextUnlocked(session) {
702
+ if (!this.store) {
703
+ throw new Error(`Workspace ${session.id} cannot be reopened without persistent Workspace state.`);
704
+ }
705
+ if (!session.managed || !session.sourceRoot || !session.targetBranch) {
706
+ throw new Error(`Workspace ${session.id} does not have enough managed-worktree metadata to recreate its execution backing.`);
707
+ }
708
+ const worktree = await createManagedWorktree({
709
+ sourcePath: session.sourceRoot,
710
+ baseRef: session.targetBranch,
711
+ config: this.config,
712
+ });
713
+ const candidateSession = {
714
+ ...session,
715
+ root: worktree.path,
716
+ status: "active",
717
+ sourceRoot: worktree.sourceRoot,
718
+ baseRef: worktree.baseRef,
719
+ baseSha: worktree.baseSha,
720
+ branch: worktree.branch,
721
+ targetBranch: worktree.targetBranch,
722
+ managed: true,
723
+ };
724
+ const workspace = this.workspaceFromSession(candidateSession, false);
725
+ try {
726
+ const context = await this.reusedWorkspaceContext(workspace);
727
+ this.store.replaceWorktreeBacking({
728
+ id: session.id,
729
+ root: worktree.path,
730
+ sourceRoot: worktree.sourceRoot,
731
+ baseRef: worktree.baseRef,
732
+ baseSha: worktree.baseSha,
733
+ branch: worktree.branch,
734
+ targetBranch: worktree.targetBranch,
735
+ });
736
+ return context;
737
+ }
738
+ catch (error) {
739
+ this.workspaces.delete(session.id);
740
+ try {
741
+ await discardFreshManagedWorktree({ worktree, config: this.config });
742
+ }
743
+ catch (cleanupError) {
744
+ const original = error instanceof Error ? error.message : String(error);
745
+ const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
746
+ throw new Error(`${original} Reopen rollback also failed: ${cleanup}`);
747
+ }
748
+ throw error;
749
+ }
750
+ }
669
751
  getWorkspaceSession(workspaceId) {
670
752
  const session = this.store?.getSession(workspaceId);
671
753
  if (session)
@@ -139,6 +139,19 @@ If histories diverge, close is refused and the worktree is preserved. Rebase and
139
139
  verify inside the worktree, then retry. The source checkout is not intentionally
140
140
  placed into a merge-conflict state.
141
141
 
142
+ A successful managed close now preserves the Workspace identity as `closed` even
143
+ though the physical worktree and managed branch are removed. Reopen that Workspace
144
+ with `open_workspace(workspaceId="ws_...")`; ForgeRelay creates fresh worktree
145
+ backing from the recorded source/target relationship and returns the same Workspace
146
+ ID. If the source checkout or target branch can no longer provide valid backing, the
147
+ open fails and the durable Workspace record remains closed.
148
+
149
+ `close_workspace(action="delete")` is never an implicit discard for active isolated
150
+ work. An active managed worktree still requires `commitMessage` and completes the
151
+ same safe finalize/integrate/cleanup lifecycle before ForgeRelay deletes its identity.
152
+ For an already-closed managed-worktree Workspace, delete removes only ForgeRelay-owned
153
+ state and does not recreate the physical backing.
154
+
142
155
  Legacy `devspace/*` managed branches remain closable when they are already stored
143
156
  in workspace metadata; only new managed branches use `forgerelay/*`.
144
157
 
@@ -295,8 +308,10 @@ until `open_workspace` reactivates the same ID by path or by `workspaceId`.
295
308
  `close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
296
309
  it removes ForgeRelay-owned Workspace state but never deletes or mutates project
297
310
  files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
298
- fast-forward-only integration / cleanup lifecycle; managed-worktree, Composite, and
299
- relayed delete semantics are completed by their later lifecycle stages.
311
+ fast-forward-only integration / cleanup lifecycle. Composite close preserves the same
312
+ `cws_...` identity and member topology; Composite `action="delete"` dissolves only
313
+ Composite-owned state without touching member Workspaces. Relayed delete remains a later
314
+ lifecycle stage.
300
315
 
301
316
  Shell commands are allowed to modify ordinary project files when that is a
302
317
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -339,17 +339,32 @@ or externally removed managed-worktree root can therefore remain diagnostically
339
339
  ordinary same-target opens no longer accumulate duplicate inventory rows;
340
340
  `action="list"` remains the formal on-demand inventory path.
341
341
 
342
- For checkout-backed Workspaces, `close_workspace` now defaults to `action="close"`:
342
+ For checkout-backed Workspaces, `close_workspace` defaults to `action="close"`:
343
343
  it marks the persistent Workspace closed, removes current conversation bindings, and
344
344
  keeps the same Workspace identity available for later `open_workspace` by path or ID.
345
345
  Closed Workspaces remain visible in inventory but ordinary execution tools reject them
346
346
  until reopened. `action="delete"` permanently removes ForgeRelay-owned checkout
347
347
  identity/state while never deleting or mutating the user's checkout directory.
348
- Managed-worktree close still requires `commitMessage` and runs the existing safe
349
- finalize lifecycle; managed-worktree delete semantics land in the next lifecycle
350
- stage. Composite close still dissolves in this stage, and Composite delete remains
351
- unavailable until its persistent lifecycle stage. Relayed delete is likewise deferred
352
- to Workspace Relay lifecycle parity.
348
+
349
+ Managed-worktree close also preserves the Workspace identity. It still requires
350
+ `commitMessage` and runs the existing BeforeWorktreeClose / commit / fast-forward-only
351
+ integration / physical cleanup / AfterWorktreeClose lifecycle, then leaves the
352
+ Workspace closed after its old worktree path and managed branch are removed. Reopening
353
+ the closed Workspace by ID recreates fresh managed-worktree backing from its recorded
354
+ source/target branch relationship while keeping the same `workspaceId`; an
355
+ unambiguous repeated source/target open can reuse the same closed identity as well.
356
+ If backing recreation fails, the record remains closed and unchanged.
357
+
358
+ `action="delete"` on an active managed-worktree Workspace is not a discard operation:
359
+ it requires `commitMessage`, completes the same safe finalize lifecycle, and only then
360
+ removes the persistent ForgeRelay identity. Deleting an already-closed worktree
361
+ Workspace removes only ForgeRelay-owned state and does not recreate backing.
362
+ Composite close now marks only the Composite record closed while preserving its identity,
363
+ name, members, and coordination metadata. Closed Composites remain inspectable and reject
364
+ member routing or mutation until reopened with the same `cws_...` ID. Composite
365
+ `action="delete"` permanently dissolves only Composite-owned state; it never closes,
366
+ finalizes, deletes, or otherwise mutates member Workspaces. Relayed delete remains
367
+ deferred to Workspace Relay lifecycle parity.
353
368
 
354
369
  Hot workspace/session activity timestamps are coalesced in memory and flushed to the
355
370
  SQLite state database in a transaction at most every five minutes; normal shutdown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -644,9 +644,124 @@ try {
644
644
  readFileSync(join(gitProject, "feature.txt"), "utf8").replace(/\r\n/g, "\n"),
645
645
  "debug worktree acceptance\n",
646
646
  );
647
+ const closedWorktreeInventory = callTool(oauth.accessToken, sessionId, 110, "open_workspace", {
648
+ action: "list",
649
+ workspaceId: worktreeWorkspaceId,
650
+ });
651
+ assert.equal(closedWorktreeInventory.structuredContent.workspaces.length, 1);
652
+ assert.equal(closedWorktreeInventory.structuredContent.workspaces[0].state, "closed");
653
+
654
+ const reopenedWorktree = callTool(oauth.accessToken, sessionId, 111, "open_workspace", {
655
+ workspaceId: worktreeWorkspaceId,
656
+ context: "none",
657
+ });
658
+ assert.equal(reopenedWorktree.structuredContent.workspaceId, worktreeWorkspaceId);
659
+ const reopenedWorktreePath = reopenedWorktree.structuredContent.worktree.path;
660
+ assert.notEqual(reopenedWorktreePath, managedWorktreePath);
661
+ assert.ok(existsSync(reopenedWorktreePath));
662
+
663
+ callTool(oauth.accessToken, sessionId, 112, "write", {
664
+ workspaceId: worktreeWorkspaceId,
665
+ path: "delete-feature.txt",
666
+ content: "debug worktree delete acceptance\n",
667
+ });
668
+ const deletedWorktree = callTool(oauth.accessToken, sessionId, 113, "close_workspace", {
669
+ workspaceId: worktreeWorkspaceId,
670
+ action: "delete",
671
+ commitMessage: "test(debug): verify 7677 worktree delete lifecycle",
672
+ });
673
+ assert.equal(deletedWorktree.structuredContent.action, "delete");
674
+ assert.equal(existsSync(reopenedWorktreePath), false);
675
+ assert.equal(
676
+ readFileSync(join(gitProject, "delete-feature.txt"), "utf8").replace(/\r\n/g, "\n"),
677
+ "debug worktree delete acceptance\n",
678
+ );
679
+ const deletedWorktreeInventory = callTool(oauth.accessToken, sessionId, 114, "open_workspace", {
680
+ action: "list",
681
+ workspaceId: worktreeWorkspaceId,
682
+ });
683
+ assert.equal(deletedWorktreeInventory.structuredContent.workspaces.length, 0);
684
+ pass(
685
+ "managed worktree lifecycle",
686
+ `${worktreeWorkspaceId} close -> closed inventory -> same-id reopen with fresh backing -> safe delete`,
687
+ );
688
+
689
+ callTool(oauth.accessToken, sessionId, 115, "write", {
690
+ workspaceId,
691
+ path: "composite-sentinel.txt",
692
+ content: "debug composite member acceptance\n",
693
+ });
694
+ const compositeOpened = callTool(oauth.accessToken, sessionId, 116, "open_workspace", {
695
+ kind: "composite",
696
+ name: "debug-lifecycle-composite",
697
+ context: "none",
698
+ });
699
+ const compositeWorkspaceId = compositeOpened.structuredContent.workspaceId;
700
+ callTool(oauth.accessToken, sessionId, 117, "open_workspace", {
701
+ action: "member",
702
+ workspaceId: compositeWorkspaceId,
703
+ memberAction: "add",
704
+ member: {
705
+ name: "code",
706
+ purpose: "Debug lifecycle member",
707
+ workspaceId,
708
+ },
709
+ });
710
+ const closedComposite = callTool(oauth.accessToken, sessionId, 118, "close_workspace", {
711
+ workspaceId: compositeWorkspaceId,
712
+ });
713
+ assert.equal(closedComposite.structuredContent.action, "close");
714
+ assert.equal(closedComposite.structuredContent.status, "closed");
715
+ assert.equal(closedComposite.structuredContent.dissolved, false);
716
+ const closedCompositeInventory = callTool(oauth.accessToken, sessionId, 119, "open_workspace", {
717
+ action: "list",
718
+ kind: "composite",
719
+ workspaceId: compositeWorkspaceId,
720
+ status: "closed",
721
+ });
722
+ assert.equal(closedCompositeInventory.structuredContent.compositeWorkspaces.length, 1);
723
+ assert.equal(closedCompositeInventory.structuredContent.compositeWorkspaces[0].state, "closed");
724
+ const closedCompositeRead = callTool(oauth.accessToken, sessionId, 120, "read", {
725
+ workspaceId: compositeWorkspaceId,
726
+ member: "code",
727
+ path: "composite-sentinel.txt",
728
+ });
729
+ assert.equal(closedCompositeRead.isError, true);
730
+
731
+ const reopenedComposite = callTool(oauth.accessToken, sessionId, 121, "open_workspace", {
732
+ workspaceId: compositeWorkspaceId,
733
+ context: "none",
734
+ });
735
+ assert.equal(reopenedComposite.structuredContent.workspaceId, compositeWorkspaceId);
736
+ assert.equal(reopenedComposite.structuredContent.status, "active");
737
+ assert.equal(reopenedComposite.structuredContent.members[0].workspaceId, workspaceId);
738
+ const reopenedCompositeRead = callTool(oauth.accessToken, sessionId, 122, "read", {
739
+ workspaceId: compositeWorkspaceId,
740
+ member: "code",
741
+ path: "composite-sentinel.txt",
742
+ });
743
+ assert.match(reopenedCompositeRead.structuredContent.result, /debug composite member acceptance/);
744
+
745
+ const deletedComposite = callTool(oauth.accessToken, sessionId, 123, "close_workspace", {
746
+ workspaceId: compositeWorkspaceId,
747
+ action: "delete",
748
+ });
749
+ assert.equal(deletedComposite.structuredContent.action, "delete");
750
+ assert.equal(deletedComposite.structuredContent.dissolved, true);
751
+ const memberAfterCompositeDelete = callTool(oauth.accessToken, sessionId, 124, "read", {
752
+ workspaceId,
753
+ path: "composite-sentinel.txt",
754
+ });
755
+ assert.match(memberAfterCompositeDelete.structuredContent.result, /debug composite member acceptance/);
756
+ const deletedCompositeInventory = callTool(oauth.accessToken, sessionId, 125, "open_workspace", {
757
+ action: "list",
758
+ kind: "composite",
759
+ workspaceId: compositeWorkspaceId,
760
+ });
761
+ assert.equal(deletedCompositeInventory.structuredContent.compositeWorkspaces.length, 0);
647
762
  pass(
648
- "managed worktree workspace close",
649
- `${closed.structuredContent.branch} -> ${closed.structuredContent.targetBranch}`,
763
+ "Composite lifecycle",
764
+ `${compositeWorkspaceId} close -> closed/non-routable -> same-id reopen -> delete; member Workspace preserved`,
650
765
  );
651
766
 
652
767
  exerciseReleaseTagHooks(oauth.accessToken, sessionId);