@akira-tl/forgerelay 0.8.2 → 0.8.3

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,12 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.3] - 2026-08-31
8
+
9
+ ### Added
10
+
11
+ - Added `workspace.tasks` with private file-backed Task Lists per persistent Workspace, preserving Task state across restart, close/reopen, managed-worktree backing replacement, and Composite close while keeping Task data out of project Git contents.
12
+
7
13
  ## [0.8.2] - 2026-08-31
8
14
 
9
15
  ### Changed
@@ -0,0 +1,29 @@
1
+ # Workspace Tasks
2
+
3
+ `workspace.tasks` 维护当前 Workspace 自己的持久 Task Lists。它是轻量工作续接状态,不是执行队列、Subagent Session、Activity 或依赖图。
4
+
5
+ ## 使用边界
6
+
7
+ - 只操作当前调用上下文中的 Workspace;参数中没有目标 `workspaceId`。
8
+ - Task state 由 ForgeRelay 保存在私有 state directory,不写入 checkout 或 managed worktree。
9
+ - 一个 Workspace 可以有多个 Task List;List 可为 `active` 或 `archived`。
10
+ - Task 状态只有 `pending`、`in_progress`、`completed`。`content` 保存继续工作真正需要的要求、阻塞点、结论或下一步,而不是日志或对话转录。
11
+ - Task/List ID 创建后保持稳定。完成 Task 不会删除它;删除必须显式执行。
12
+
13
+ ## 操作
14
+
15
+ 先用 `operation="get"` 读取当前 Task state。
16
+
17
+ List 操作:
18
+
19
+ - `list.create`:创建具名 List,可指定 `position`。
20
+ - `list.update`:修改 `name`、`state` 或 `position`;`state="archived"` 用于归档,改回 `active` 即重新激活。
21
+ - `list.delete`:显式删除 List 及其 Tasks。
22
+
23
+ Task 操作:
24
+
25
+ - `task.create`:在一个 List 中创建 Task;需要 `subject`,可提供 `content`、`status`、`position`。
26
+ - `task.update`:修改 `subject`、`content`、`status` 或 `position`;至少提供一个变更字段。
27
+ - `task.delete`:显式删除 Task。
28
+
29
+ Task 修改使用独立的 revision/fingerprint 域;它不改变 Workspace bootstrap `contextFingerprint`。
@@ -39,6 +39,11 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
39
39
  description: "Read-only semantic code navigation backed by external Language servers.",
40
40
  whenToRead: "Read before using code.intelligence or configuring Language servers.",
41
41
  },
42
+ {
43
+ name: "workspace-tasks",
44
+ description: "Persistent Task Lists owned by the current Workspace.",
45
+ whenToRead: "Read before creating or maintaining Workspace Tasks.",
46
+ },
42
47
  {
43
48
  name: "batch-execution",
44
49
  description: "One-call execution of multiple independent ForgeRelay core operations.",
@@ -87,6 +92,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
87
92
  "hooks.lifecycle",
88
93
  "capability-guides.read",
89
94
  "code.intelligence",
95
+ "workspace.tasks",
90
96
  ];
91
97
  if (config.toolMode !== "codex") {
92
98
  capabilities.push("batch.execute");
@@ -130,6 +130,52 @@ export function createCapabilityRegistry(dependencies) {
130
130
  line: z.number().int(),
131
131
  column: z.number().int(),
132
132
  };
133
+ const workspaceTaskStatus = z.enum(["pending", "in_progress", "completed"]);
134
+ const workspaceTaskListState = z.enum(["active", "archived"]);
135
+ const workspaceTasksInput = z.union([
136
+ z.object({ operation: z.literal("get") }).strict(),
137
+ z.object({
138
+ operation: z.literal("list.create"),
139
+ name: z.string().trim().min(1),
140
+ position: z.number().int().min(0).optional(),
141
+ }).strict(),
142
+ z.object({
143
+ operation: z.literal("list.update"),
144
+ listId: z.string().min(1),
145
+ name: z.string().trim().min(1).optional(),
146
+ state: workspaceTaskListState.optional(),
147
+ position: z.number().int().min(0).optional(),
148
+ }).strict().refine((input) => input.name !== undefined || input.state !== undefined || input.position !== undefined, { message: "list.update requires at least one field to change" }),
149
+ z.object({
150
+ operation: z.literal("list.delete"),
151
+ listId: z.string().min(1),
152
+ }).strict(),
153
+ z.object({
154
+ operation: z.literal("task.create"),
155
+ listId: z.string().min(1),
156
+ subject: z.string().trim().min(1),
157
+ content: z.string().optional(),
158
+ status: workspaceTaskStatus.optional(),
159
+ position: z.number().int().min(0).optional(),
160
+ }).strict(),
161
+ z.object({
162
+ operation: z.literal("task.update"),
163
+ listId: z.string().min(1),
164
+ taskId: z.string().min(1),
165
+ subject: z.string().trim().min(1).optional(),
166
+ content: z.string().optional(),
167
+ status: workspaceTaskStatus.optional(),
168
+ position: z.number().int().min(0).optional(),
169
+ }).strict().refine((input) => input.subject !== undefined
170
+ || input.content !== undefined
171
+ || input.status !== undefined
172
+ || input.position !== undefined, { message: "task.update requires at least one field to change" }),
173
+ z.object({
174
+ operation: z.literal("task.delete"),
175
+ listId: z.string().min(1),
176
+ taskId: z.string().min(1),
177
+ }).strict(),
178
+ ]);
133
179
  const subagentSessionInput = z.discriminatedUnion("operation", [
134
180
  z.object({
135
181
  operation: z.literal("start"),
@@ -190,11 +236,11 @@ export function createCapabilityRegistry(dependencies) {
190
236
  readGuideBeforeFirstUse: true,
191
237
  batchPolicy: "parallel",
192
238
  inputSchema: hooksCheckInput,
193
- availability: () => ({ available: true }),
239
+ availability: (context) => filesystemWorkspaceAvailability(context),
194
240
  run: async (_input, context) => ({
195
241
  value: {
196
242
  ok: true,
197
- ...await dependencies.inspectHooks(context.workspaceRoot),
243
+ ...await dependencies.inspectHooks(requireWorkspaceRoot(context)),
198
244
  },
199
245
  }),
200
246
  },
@@ -206,10 +252,7 @@ export function createCapabilityRegistry(dependencies) {
206
252
  readGuideBeforeFirstUse: true,
207
253
  batchPolicy: "serial",
208
254
  inputSchema: z.object({}).strict(),
209
- availability: () => ({
210
- available: dependencies.reviewChanges?.available ?? false,
211
- reason: dependencies.reviewChanges?.unavailableReason,
212
- }),
255
+ availability: (context) => filesystemWorkspaceAvailability(context, dependencies.reviewChanges?.available ?? false, dependencies.reviewChanges?.unavailableReason),
213
256
  run: async (_input, context) => dependencies.reviewChanges.run(context),
214
257
  }]
215
258
  : []),
@@ -221,11 +264,23 @@ export function createCapabilityRegistry(dependencies) {
221
264
  readGuideBeforeFirstUse: true,
222
265
  batchPolicy: "parallel",
223
266
  inputSchema: codeIntelligenceInput,
267
+ availability: (context) => filesystemWorkspaceAvailability(context, dependencies.codeIntelligence?.available ?? false, dependencies.codeIntelligence?.unavailableReason),
268
+ run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
269
+ }]
270
+ : []),
271
+ ...(dependencies.workspaceTasks
272
+ ? [{
273
+ name: "workspace.tasks",
274
+ description: "Maintain persistent Task Lists owned by the current Workspace.",
275
+ guideName: "workspace-tasks",
276
+ readGuideBeforeFirstUse: true,
277
+ batchPolicy: "serial",
278
+ inputSchema: workspaceTasksInput,
224
279
  availability: () => ({
225
- available: dependencies.codeIntelligence?.available ?? false,
226
- reason: dependencies.codeIntelligence?.unavailableReason,
280
+ available: dependencies.workspaceTasks?.available ?? false,
281
+ reason: dependencies.workspaceTasks?.unavailableReason,
227
282
  }),
228
- run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
283
+ run: async (input, context, options) => dependencies.workspaceTasks.run(input, context, options),
229
284
  }]
230
285
  : []),
231
286
  ...(dependencies.subagentSession
@@ -236,10 +291,7 @@ export function createCapabilityRegistry(dependencies) {
236
291
  readGuideBeforeFirstUse: true,
237
292
  batchPolicy: "unsupported",
238
293
  inputSchema: subagentSessionInput,
239
- availability: () => ({
240
- available: dependencies.subagentSession?.available ?? false,
241
- reason: dependencies.subagentSession?.unavailableReason,
242
- }),
294
+ availability: (context) => filesystemWorkspaceAvailability(context, dependencies.subagentSession?.available ?? false, dependencies.subagentSession?.unavailableReason),
243
295
  run: async (input, context, options) => dependencies.subagentSession.run(input, context, options),
244
296
  }]
245
297
  : []),
@@ -251,10 +303,7 @@ export function createCapabilityRegistry(dependencies) {
251
303
  readGuideBeforeFirstUse: true,
252
304
  batchPolicy: "unsupported",
253
305
  inputSchema: batchExecuteInputSchema,
254
- availability: () => ({
255
- available: dependencies.batchExecute?.available ?? false,
256
- reason: dependencies.batchExecute?.unavailableReason,
257
- }),
306
+ availability: (context) => filesystemWorkspaceAvailability(context, dependencies.batchExecute?.available ?? false, dependencies.batchExecute?.unavailableReason),
258
307
  run: async (input, context, options) => dependencies.batchExecute.run(input, context, options),
259
308
  }]
260
309
  : []),
@@ -277,15 +326,27 @@ export function createCapabilityRegistry(dependencies) {
277
326
  path: z.string().min(1),
278
327
  }).strict(),
279
328
  nativeFileArgument: "file",
280
- availability: () => ({
281
- available: dependencies.downloadArtifact?.available ?? false,
282
- reason: dependencies.downloadArtifact?.unavailableReason,
283
- }),
329
+ availability: (context) => filesystemWorkspaceAvailability(context, dependencies.downloadArtifact?.available ?? false, dependencies.downloadArtifact?.unavailableReason),
284
330
  run: async (input, context) => dependencies.downloadArtifact.run(input, context),
285
331
  }]
286
332
  : []),
287
333
  ]);
288
334
  }
335
+ function filesystemWorkspaceAvailability(context, available = true, reason) {
336
+ if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
337
+ return {
338
+ available: false,
339
+ reason: "This capability requires a filesystem-backed Workspace.",
340
+ };
341
+ }
342
+ return { available, ...(reason ? { reason } : {}) };
343
+ }
344
+ function requireWorkspaceRoot(context) {
345
+ if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
346
+ throw new CapabilityError("capability_unavailable", "This capability requires a filesystem-backed Workspace.");
347
+ }
348
+ return context.workspaceRoot;
349
+ }
289
350
  function isRecord(value) {
290
351
  return typeof value === "object" && value !== null && !Array.isArray(value);
291
352
  }
package/dist/server.js CHANGED
@@ -21,7 +21,7 @@ import { HostTurnStore } from "./activity/host-turn-store.js";
21
21
  import { registerActivityQueryTools } from "./activity/mcp-query-tools.js";
22
22
  import { ActivityLifecycle, } from "./activity/lifecycle.js";
23
23
  import { ActivityQueryService } from "./activity/query-service.js";
24
- import { buildCapabilityFingerprint } from "./capabilities.js";
24
+ import { buildCapabilityFingerprint, loadCapabilityGuides } from "./capabilities.js";
25
25
  import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
26
26
  import { deletePath, renamePath } from "./file-mutations.js";
27
27
  import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
@@ -53,6 +53,7 @@ import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP
53
53
  import { shutdownHttpServer } from "./server-shutdown.js";
54
54
  import { formatPathForPrompt } from "./skills.js";
55
55
  import { createWorkspaceStore } from "./workspace-store.js";
56
+ import { WorkspaceTaskStore } from "./workspace-tasks.js";
56
57
  import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
57
58
  import { formatAvailableSubagentProfile, summarizeSubagentProfile } from "./subagents/profiles.js";
58
59
  import { formatSubagentProviderAvailabilitySummary, formatUnavailableSubagentProvider, getSubagentProviderAvailabilitySnapshot, } from "./subagents/providers/availability.js";
@@ -821,6 +822,7 @@ function workspaceHookInvocation(workspace) {
821
822
  function capabilityContextFor(workspace) {
822
823
  return {
823
824
  workspaceId: workspace.id,
825
+ workspaceKind: "workspace",
824
826
  workspaceRoot: workspace.root,
825
827
  guides: workspace.capabilityGuides.map((guide) => ({
826
828
  name: guide.name,
@@ -830,6 +832,56 @@ function capabilityContextFor(workspace) {
830
832
  })),
831
833
  };
832
834
  }
835
+ function compositeCapabilityContext(workspaceId, guides) {
836
+ return {
837
+ workspaceId,
838
+ workspaceKind: "composite",
839
+ guides: guides.map((guide) => ({
840
+ name: guide.name,
841
+ description: guide.description,
842
+ whenToRead: guide.whenToRead,
843
+ path: formatPathForPrompt(guide.filePath),
844
+ })),
845
+ };
846
+ }
847
+ function requireCapabilityWorkspaceRoot(context) {
848
+ if (!context.workspaceRoot) {
849
+ throw new CapabilityError("capability_unavailable", `Capability execution requires a filesystem-backed Workspace; ${context.workspaceId} is ${context.workspaceKind}.`);
850
+ }
851
+ return context.workspaceRoot;
852
+ }
853
+ function runWorkspaceTasksCapability(store, workspaceId, input) {
854
+ switch (input.operation) {
855
+ case "get":
856
+ return store.read(workspaceId);
857
+ case "list.create":
858
+ return store.createList(workspaceId, { name: input.name, position: input.position });
859
+ case "list.update":
860
+ return store.updateList(workspaceId, input.listId, {
861
+ name: input.name,
862
+ state: input.state,
863
+ position: input.position,
864
+ });
865
+ case "list.delete":
866
+ return store.deleteList(workspaceId, input.listId);
867
+ case "task.create":
868
+ return store.createTask(workspaceId, input.listId, {
869
+ subject: input.subject,
870
+ content: input.content,
871
+ status: input.status,
872
+ position: input.position,
873
+ });
874
+ case "task.update":
875
+ return store.updateTask(workspaceId, input.listId, input.taskId, {
876
+ subject: input.subject,
877
+ content: input.content,
878
+ status: input.status,
879
+ position: input.position,
880
+ });
881
+ case "task.delete":
882
+ return store.deleteTask(workspaceId, input.listId, input.taskId);
883
+ }
884
+ }
833
885
  async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
834
886
  return reviewCheckpoints.reviewChanges({
835
887
  workspaceId: workspace.id,
@@ -1192,6 +1244,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1192
1244
  const connectionScopeId = `mcp-connection:${randomUUID()}`;
1193
1245
  const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
1194
1246
  const compositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
1247
+ const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
1248
+ const compositeTaskGuides = loadCapabilityGuides(config).filter((guide) => guide.name === "workspace-tasks");
1195
1249
  const compositeActivity = new CompositeActivityCoordinator(compositeWorkspaces, activityQueries, remoteWorkspaces);
1196
1250
  const resolveExecutionTarget = (workspaceId, memberName) => {
1197
1251
  if (!compositeWorkspaces.has(workspaceId)) {
@@ -1248,6 +1302,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1248
1302
  const capabilityRegistry = createCapabilityRegistry({
1249
1303
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
1250
1304
  ...subagentMcp.registryDependencies,
1305
+ workspaceTasks: {
1306
+ available: true,
1307
+ run: async (input, context) => ({
1308
+ value: runWorkspaceTasksCapability(workspaceTasks, context.workspaceId, input),
1309
+ }),
1310
+ },
1251
1311
  batchExecute: {
1252
1312
  available: batchExecuteAvailable,
1253
1313
  unavailableReason: batchExecuteAvailable
@@ -1270,7 +1330,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1270
1330
  run: async (input, context, options) => {
1271
1331
  try {
1272
1332
  return {
1273
- value: await codeIntelligence.run(context.workspaceRoot, input, { signal: options.signal }),
1333
+ value: await codeIntelligence.run(requireCapabilityWorkspaceRoot(context), input, { signal: options.signal }),
1274
1334
  };
1275
1335
  }
1276
1336
  catch (error) {
@@ -1289,7 +1349,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1289
1349
  run: async (context) => {
1290
1350
  const review = await reviewWorkspaceChanges(reviewCheckpoints, {
1291
1351
  id: context.workspaceId,
1292
- root: context.workspaceRoot,
1352
+ root: requireCapabilityWorkspaceRoot(context),
1293
1353
  });
1294
1354
  return {
1295
1355
  value: {
@@ -1317,7 +1377,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1317
1377
  const downloaded = await downloadIncomingArtifact({
1318
1378
  registry: incomingArtifactRegistry,
1319
1379
  workspaceId: context.workspaceId,
1320
- workspaceRoot: context.workspaceRoot,
1380
+ workspaceRoot: requireCapabilityWorkspaceRoot(context),
1321
1381
  maxFileBytes: config.artifactMaxFileBytes,
1322
1382
  file: input.file,
1323
1383
  path: input.path,
@@ -2398,6 +2458,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2398
2458
  const composite = workspaceId !== undefined
2399
2459
  ? compositeWorkspaces.open(workspaceId)
2400
2460
  : compositeWorkspaces.create(name ?? "");
2461
+ workspaceTasks.initializeWorkspace(composite.id);
2462
+ const compositeTaskContext = compositeCapabilityContext(composite.id, compositeTaskGuides);
2463
+ const compositeCapabilityCatalog = capabilityRegistry.catalog(compositeTaskContext);
2464
+ const compositeCapabilityGuides = compositeTaskGuides.map((guide) => ({
2465
+ name: guide.name,
2466
+ description: guide.description,
2467
+ whenToRead: guide.whenToRead,
2468
+ path: formatPathForPrompt(guide.filePath),
2469
+ }));
2401
2470
  const memberContext = memberName
2402
2471
  ? await loadCompositeMemberContext(composite.id, memberName, context ?? "auto", conversationScopeId, protectedWorkspaceIds)
2403
2472
  : undefined;
@@ -2408,6 +2477,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2408
2477
  ? `Members: ${composite.members.map((member) => `${member.name} — ${member.purpose}`).join("; ")}.`
2409
2478
  : "This Composite Workspace currently has no members.",
2410
2479
  "Member names and purposes are structural context and are always returned when this Composite Workspace is opened. context=auto/full/none controls only heavy member bootstrap context, not this Composite identity.",
2480
+ compositeCapabilityCatalog.length > 0
2481
+ ? `Composite-owned capabilities: ${compositeCapabilityCatalog.map((entry) => entry.name).join(", ")}. Use these without member because their state belongs to the Composite Workspace itself.`
2482
+ : undefined,
2411
2483
  composite.members.length > 0
2412
2484
  ? "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."
2413
2485
  : undefined,
@@ -2435,6 +2507,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2435
2507
  status: composite.status,
2436
2508
  state: composite.status,
2437
2509
  members: composite.members,
2510
+ capabilityCatalog: compositeCapabilityCatalog,
2511
+ capabilityGuides: compositeCapabilityGuides,
2438
2512
  ...(memberContext ? { memberContext } : {}),
2439
2513
  instruction,
2440
2514
  },
@@ -2547,6 +2621,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2547
2621
  conversationScopeId,
2548
2622
  protectedWorkspaceIds,
2549
2623
  });
2624
+ workspaceTasks.initializeWorkspace(workspace.id);
2550
2625
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
2551
2626
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
2552
2627
  const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
@@ -2808,6 +2883,74 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2808
2883
  openWorldHint: true,
2809
2884
  },
2810
2885
  }, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
2886
+ if (name === "workspace.tasks" && compositeWorkspaces.has(workspaceId)) {
2887
+ if (member !== undefined) {
2888
+ throw new Error(`workspace.tasks belongs to Composite Workspace ${workspaceId} itself and does not accept member.`);
2889
+ }
2890
+ if (!compositeWorkspaces.isActive(workspaceId)) {
2891
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
2892
+ }
2893
+ const startedAt = performance.now();
2894
+ const context = compositeCapabilityContext(workspaceId, compositeTaskGuides);
2895
+ try {
2896
+ if (action === "run") {
2897
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, context, {
2898
+ nativeFile: file,
2899
+ signal: extra.signal,
2900
+ requestMeta: extra._meta,
2901
+ sessionId: extra.sessionId,
2902
+ });
2903
+ const result = {
2904
+ content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
2905
+ structuredContent: { name, action, result: execution.value },
2906
+ };
2907
+ logToolCall(config, {
2908
+ tool: toolNames.capability,
2909
+ capability: name,
2910
+ action,
2911
+ success: true,
2912
+ durationMs: Math.round(performance.now() - startedAt),
2913
+ });
2914
+ return result;
2915
+ }
2916
+ const capability = capabilityRegistry.describe(name, context);
2917
+ const result = {
2918
+ content: [textBlock([
2919
+ `${capability.name}: ${capability.description}`,
2920
+ `Available: ${capability.available}`,
2921
+ `Guide: ${capability.guide.path}`,
2922
+ capability.guide.readBeforeFirstUse
2923
+ ? "Read the guide before first use when this contract is unfamiliar."
2924
+ : undefined,
2925
+ ].filter(Boolean).join("\n"))],
2926
+ structuredContent: { name, action, capability },
2927
+ };
2928
+ logToolCall(config, {
2929
+ tool: toolNames.capability,
2930
+ capability: name,
2931
+ action,
2932
+ success: true,
2933
+ durationMs: Math.round(performance.now() - startedAt),
2934
+ });
2935
+ return result;
2936
+ }
2937
+ catch (error) {
2938
+ if (extra.signal.aborted)
2939
+ throw error;
2940
+ const capabilityError = error instanceof CapabilityError
2941
+ ? error
2942
+ : new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
2943
+ return {
2944
+ content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
2945
+ structuredContent: {
2946
+ name,
2947
+ action,
2948
+ error: { code: capabilityError.code, message: capabilityError.message },
2949
+ },
2950
+ isError: true,
2951
+ };
2952
+ }
2953
+ }
2811
2954
  const target = resolveExecutionTarget(workspaceId, member);
2812
2955
  const executionWorkspaceId = target.executionWorkspaceId;
2813
2956
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
@@ -2972,6 +3115,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2972
3115
  const composite = action === "delete"
2973
3116
  ? compositeWorkspaces.dissolve(workspaceId)
2974
3117
  : compositeWorkspaces.close(workspaceId);
3118
+ if (action === "delete")
3119
+ workspaceTasks.deleteWorkspace(workspaceId);
2975
3120
  compositeActivity.forgetComposite(workspaceId);
2976
3121
  workspacePanelStates.delete(workspaceId);
2977
3122
  const result = [
@@ -3038,6 +3183,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3038
3183
  payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3039
3184
  operation: async () => {
3040
3185
  workspaces.deleteWorkspace(session.id);
3186
+ workspaceTasks.deleteWorkspace(session.id);
3041
3187
  await reviewCheckpoints.releaseWorkspace(session.id);
3042
3188
  const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3043
3189
  return {
@@ -3080,6 +3226,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3080
3226
  payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3081
3227
  operation: async () => {
3082
3228
  workspaces.deleteWorkspace(session.id);
3229
+ workspaceTasks.deleteWorkspace(session.id);
3083
3230
  await reviewCheckpoints.releaseWorkspace(session.id);
3084
3231
  const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
3085
3232
  return {
@@ -3142,6 +3289,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3142
3289
  await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
3143
3290
  if (action === "delete") {
3144
3291
  workspaces.deleteWorkspace(workspace.id);
3292
+ workspaceTasks.deleteWorkspace(workspace.id);
3145
3293
  }
3146
3294
  const result = [
3147
3295
  action === "delete"
@@ -3237,7 +3385,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3237
3385
  member: z
3238
3386
  .string()
3239
3387
  .optional()
3240
- .describe("Required for a Composite Workspace; explicit member name that owns this operation."),
3388
+ .describe("Required for Composite member-scoped file reads. Omit only when reading an advertised Composite-owned capability guide."),
3241
3389
  path: z
3242
3390
  .string()
3243
3391
  .optional()
@@ -3279,6 +3427,29 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3279
3427
  if ((path === undefined) === (paths === undefined)) {
3280
3428
  throw new Error("read requires exactly one of path or paths.");
3281
3429
  }
3430
+ if (compositeWorkspaces.has(workspaceId) && member === undefined && path !== undefined) {
3431
+ const guide = compositeTaskGuides.find((candidate) => formatPathForPrompt(candidate.filePath) === path || candidate.filePath === path);
3432
+ if (guide) {
3433
+ if (!compositeWorkspaces.isActive(workspaceId)) {
3434
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
3435
+ }
3436
+ const startedAt = performance.now();
3437
+ const raw = readFileSync(guide.filePath, "utf8");
3438
+ const start = (offset ?? 1) - 1;
3439
+ const end = limit === undefined ? undefined : start + limit;
3440
+ const result = raw.split("\n").slice(start, end).join("\n");
3441
+ logToolCall(config, {
3442
+ tool: toolNames.read,
3443
+ path: formatPathForPrompt(guide.filePath),
3444
+ success: true,
3445
+ durationMs: Math.round(performance.now() - startedAt),
3446
+ });
3447
+ return {
3448
+ content: [textBlock(result)],
3449
+ structuredContent: { result },
3450
+ };
3451
+ }
3452
+ }
3282
3453
  const target = resolveExecutionTarget(workspaceId, member);
3283
3454
  const executionWorkspaceId = target.executionWorkspaceId;
3284
3455
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
@@ -19,6 +19,9 @@ export class SubagentSessionCapability {
19
19
  this.ownerAliveOverride = options.ownerAlive;
20
20
  }
21
21
  async run(input, context, options) {
22
+ if (!context.workspaceRoot) {
23
+ throw new CapabilityError("capability_unavailable", "subagent.session requires a filesystem-backed Workspace.");
24
+ }
22
25
  const manager = new SubagentSessionManager(this.config, {
23
26
  launch: (request) => this.launch(request),
24
27
  });
@@ -0,0 +1,351 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import * as z from "zod/v4";
5
+ const TASK_STATE_VERSION = 1;
6
+ const MAX_TASK_STATE_BYTES = 2 * 1024 * 1024;
7
+ const MAX_TASK_LISTS = 100;
8
+ const MAX_TASKS_PER_LIST = 500;
9
+ const MAX_LIST_NAME_LENGTH = 120;
10
+ const MAX_TASK_SUBJECT_LENGTH = 240;
11
+ const MAX_TASK_CONTENT_LENGTH = 64 * 1024;
12
+ const workspaceTaskSchema = z.object({
13
+ id: z.string().regex(/^tsk_[a-f0-9]{10}$/),
14
+ status: z.enum(["pending", "in_progress", "completed"]),
15
+ subject: z.string().min(1).max(MAX_TASK_SUBJECT_LENGTH),
16
+ content: z.string().max(MAX_TASK_CONTENT_LENGTH),
17
+ }).strict();
18
+ const workspaceTaskListSchema = z.object({
19
+ id: z.string().regex(/^tl_[a-f0-9]{10}$/),
20
+ name: z.string().min(1).max(MAX_LIST_NAME_LENGTH),
21
+ state: z.enum(["active", "archived"]),
22
+ revision: z.number().int().positive(),
23
+ tasks: z.array(workspaceTaskSchema).max(MAX_TASKS_PER_LIST),
24
+ }).strict();
25
+ const workspaceTaskStateSchema = z.object({
26
+ version: z.literal(TASK_STATE_VERSION),
27
+ revision: z.number().int().nonnegative(),
28
+ lists: z.array(workspaceTaskListSchema).max(MAX_TASK_LISTS),
29
+ }).strict().superRefine((state, context) => {
30
+ const listIds = new Set();
31
+ const taskIds = new Set();
32
+ state.lists.forEach((list, listIndex) => {
33
+ if (listIds.has(list.id)) {
34
+ context.addIssue({
35
+ code: "custom",
36
+ path: ["lists", listIndex, "id"],
37
+ message: `Duplicate Task List id ${list.id}.`,
38
+ });
39
+ }
40
+ listIds.add(list.id);
41
+ list.tasks.forEach((task, taskIndex) => {
42
+ if (taskIds.has(task.id)) {
43
+ context.addIssue({
44
+ code: "custom",
45
+ path: ["lists", listIndex, "tasks", taskIndex, "id"],
46
+ message: `Duplicate Task id ${task.id}.`,
47
+ });
48
+ }
49
+ taskIds.add(task.id);
50
+ });
51
+ });
52
+ });
53
+ export class WorkspaceTaskStore {
54
+ stateDir;
55
+ constructor(stateDir) {
56
+ this.stateDir = stateDir;
57
+ }
58
+ ensureWorkspace(workspaceId) {
59
+ const id = normalizeWorkspaceId(workspaceId);
60
+ const loaded = this.tryReadState(id);
61
+ if (loaded)
62
+ return snapshot(loaded.state, loaded.fingerprint);
63
+ return this.writeState(id, emptyState());
64
+ }
65
+ initializeWorkspace(workspaceId) {
66
+ const id = normalizeWorkspaceId(workspaceId);
67
+ const directory = this.workspaceStateDir(id);
68
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
69
+ try {
70
+ writeFileSync(this.statePath(id), `${JSON.stringify(emptyState(), null, 2)}\n`, {
71
+ mode: 0o600,
72
+ flag: "wx",
73
+ });
74
+ }
75
+ catch (error) {
76
+ if (!isErrno(error, "EEXIST"))
77
+ throw error;
78
+ }
79
+ }
80
+ read(workspaceId) {
81
+ return this.ensureWorkspace(workspaceId);
82
+ }
83
+ createList(workspaceId, input) {
84
+ return this.mutate(workspaceId, (state) => {
85
+ if (state.lists.length >= MAX_TASK_LISTS) {
86
+ throw new Error(`Workspace Task List limit is ${MAX_TASK_LISTS}.`);
87
+ }
88
+ const list = {
89
+ id: `tl_${randomBytes(5).toString("hex")}`,
90
+ name: normalizeListName(input.name),
91
+ state: "active",
92
+ revision: 1,
93
+ tasks: [],
94
+ };
95
+ const position = normalizeInsertPosition(input.position, state.lists.length, "Task List");
96
+ state.lists.splice(position, 0, list);
97
+ return true;
98
+ });
99
+ }
100
+ updateList(workspaceId, listId, input) {
101
+ return this.mutate(workspaceId, (state) => {
102
+ const index = requireListIndex(state, listId);
103
+ const list = state.lists[index];
104
+ const nextName = input.name === undefined ? list.name : normalizeListName(input.name);
105
+ const nextState = input.state ?? list.state;
106
+ const nextPosition = input.position === undefined
107
+ ? index
108
+ : normalizeMovePosition(input.position, state.lists.length, "Task List");
109
+ const metadataChanged = nextName !== list.name || nextState !== list.state;
110
+ const positionChanged = nextPosition !== index;
111
+ if (!metadataChanged && !positionChanged)
112
+ return false;
113
+ list.name = nextName;
114
+ list.state = nextState;
115
+ list.revision += 1;
116
+ if (positionChanged)
117
+ moveArrayEntry(state.lists, index, nextPosition);
118
+ return true;
119
+ });
120
+ }
121
+ deleteList(workspaceId, listId) {
122
+ return this.mutate(workspaceId, (state) => {
123
+ state.lists.splice(requireListIndex(state, listId), 1);
124
+ return true;
125
+ });
126
+ }
127
+ createTask(workspaceId, listId, input) {
128
+ return this.mutate(workspaceId, (state) => {
129
+ const list = requireList(state, listId);
130
+ if (list.tasks.length >= MAX_TASKS_PER_LIST) {
131
+ throw new Error(`Task limit per Task List is ${MAX_TASKS_PER_LIST}.`);
132
+ }
133
+ const task = {
134
+ id: `tsk_${randomBytes(5).toString("hex")}`,
135
+ status: input.status ?? "pending",
136
+ subject: normalizeTaskSubject(input.subject),
137
+ content: normalizeTaskContent(input.content ?? ""),
138
+ };
139
+ const position = normalizeInsertPosition(input.position, list.tasks.length, "Task");
140
+ list.tasks.splice(position, 0, task);
141
+ list.revision += 1;
142
+ return true;
143
+ });
144
+ }
145
+ updateTask(workspaceId, listId, taskId, input) {
146
+ return this.mutate(workspaceId, (state) => {
147
+ const list = requireList(state, listId);
148
+ const index = requireTaskIndex(list, taskId);
149
+ const task = list.tasks[index];
150
+ const nextStatus = input.status ?? task.status;
151
+ const nextSubject = input.subject === undefined ? task.subject : normalizeTaskSubject(input.subject);
152
+ const nextContent = input.content === undefined ? task.content : normalizeTaskContent(input.content);
153
+ const nextPosition = input.position === undefined
154
+ ? index
155
+ : normalizeMovePosition(input.position, list.tasks.length, "Task");
156
+ const fieldsChanged = nextStatus !== task.status || nextSubject !== task.subject || nextContent !== task.content;
157
+ const positionChanged = nextPosition !== index;
158
+ if (!fieldsChanged && !positionChanged)
159
+ return false;
160
+ task.status = nextStatus;
161
+ task.subject = nextSubject;
162
+ task.content = nextContent;
163
+ if (positionChanged)
164
+ moveArrayEntry(list.tasks, index, nextPosition);
165
+ list.revision += 1;
166
+ return true;
167
+ });
168
+ }
169
+ deleteTask(workspaceId, listId, taskId) {
170
+ return this.mutate(workspaceId, (state) => {
171
+ const list = requireList(state, listId);
172
+ list.tasks.splice(requireTaskIndex(list, taskId), 1);
173
+ list.revision += 1;
174
+ return true;
175
+ });
176
+ }
177
+ deleteWorkspace(workspaceId) {
178
+ const id = normalizeWorkspaceId(workspaceId);
179
+ rmSync(this.statePath(id), { force: true });
180
+ try {
181
+ rmdirSync(this.workspaceStateDir(id));
182
+ }
183
+ catch (error) {
184
+ if (!isErrno(error, "ENOENT") && !isErrno(error, "ENOTEMPTY") && !isErrno(error, "EEXIST")) {
185
+ throw error;
186
+ }
187
+ }
188
+ }
189
+ mutate(workspaceId, mutateState) {
190
+ const id = normalizeWorkspaceId(workspaceId);
191
+ const loaded = this.tryReadState(id);
192
+ const state = loaded ? cloneState(loaded.state) : emptyState();
193
+ if (!mutateState(state)) {
194
+ return loaded
195
+ ? snapshot(loaded.state, loaded.fingerprint)
196
+ : this.writeState(id, state);
197
+ }
198
+ state.revision += 1;
199
+ return this.writeState(id, state);
200
+ }
201
+ tryReadState(workspaceId) {
202
+ const path = this.statePath(workspaceId);
203
+ let raw;
204
+ try {
205
+ raw = readFileSync(path);
206
+ }
207
+ catch (error) {
208
+ if (isErrno(error, "ENOENT"))
209
+ return undefined;
210
+ throw error;
211
+ }
212
+ if (raw.byteLength > MAX_TASK_STATE_BYTES) {
213
+ throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
214
+ }
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(raw.toString("utf8"));
218
+ }
219
+ catch (error) {
220
+ throw new Error(`Workspace Task state is not valid JSON: ${errorMessage(error)}`);
221
+ }
222
+ const validated = workspaceTaskStateSchema.safeParse(parsed);
223
+ if (!validated.success) {
224
+ const details = validated.error.issues
225
+ .map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "state"}: ${issue.message}`)
226
+ .join("; ");
227
+ throw new Error(`Workspace Task state has an unsupported or invalid format: ${details}`);
228
+ }
229
+ return {
230
+ state: cloneState(validated.data),
231
+ fingerprint: fingerprint(raw),
232
+ };
233
+ }
234
+ writeState(workspaceId, state) {
235
+ const workspaceDir = this.workspaceStateDir(workspaceId);
236
+ mkdirSync(workspaceDir, { recursive: true, mode: 0o700 });
237
+ const validated = workspaceTaskStateSchema.parse(state);
238
+ const serialized = `${JSON.stringify(validated, null, 2)}\n`;
239
+ if (Buffer.byteLength(serialized, "utf8") > MAX_TASK_STATE_BYTES) {
240
+ throw new Error(`Workspace Task state exceeds ${MAX_TASK_STATE_BYTES} bytes.`);
241
+ }
242
+ const statePath = this.statePath(workspaceId);
243
+ const tempPath = `${statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
244
+ try {
245
+ writeFileSync(tempPath, serialized, { mode: 0o600 });
246
+ renameSync(tempPath, statePath);
247
+ }
248
+ finally {
249
+ rmSync(tempPath, { force: true });
250
+ }
251
+ return snapshot(validated, fingerprint(Buffer.from(serialized, "utf8")));
252
+ }
253
+ workspaceStateDir(workspaceId) {
254
+ return join(this.stateDir, "workspaces", workspaceId);
255
+ }
256
+ statePath(workspaceId) {
257
+ return join(this.workspaceStateDir(workspaceId), "tasks.json");
258
+ }
259
+ }
260
+ function emptyState() {
261
+ return { version: TASK_STATE_VERSION, revision: 0, lists: [] };
262
+ }
263
+ function snapshot(state, stateFingerprint) {
264
+ return {
265
+ ...cloneState(state),
266
+ fingerprint: stateFingerprint,
267
+ };
268
+ }
269
+ function cloneState(state) {
270
+ return {
271
+ version: state.version,
272
+ revision: state.revision,
273
+ lists: state.lists.map((list) => ({
274
+ ...list,
275
+ tasks: list.tasks.map((task) => ({ ...task })),
276
+ })),
277
+ };
278
+ }
279
+ function requireList(state, listId) {
280
+ return state.lists[requireListIndex(state, listId)];
281
+ }
282
+ function requireListIndex(state, listId) {
283
+ const index = state.lists.findIndex((list) => list.id === listId);
284
+ if (index < 0)
285
+ throw new Error(`Unknown Task List ${listId}.`);
286
+ return index;
287
+ }
288
+ function requireTaskIndex(list, taskId) {
289
+ const index = list.tasks.findIndex((task) => task.id === taskId);
290
+ if (index < 0)
291
+ throw new Error(`Task List ${list.id} has no Task ${taskId}.`);
292
+ return index;
293
+ }
294
+ function normalizeWorkspaceId(workspaceId) {
295
+ const value = workspaceId.trim();
296
+ if (!/^[a-z][a-z0-9_-]{1,127}$/.test(value)) {
297
+ throw new Error("Workspace ID is not valid for Workspace Task state.");
298
+ }
299
+ return value;
300
+ }
301
+ function normalizeListName(name) {
302
+ const value = name.trim();
303
+ if (!value)
304
+ throw new Error("Task List name must not be empty.");
305
+ if (value.length > MAX_LIST_NAME_LENGTH) {
306
+ throw new Error(`Task List name must be at most ${MAX_LIST_NAME_LENGTH} characters.`);
307
+ }
308
+ return value;
309
+ }
310
+ function normalizeTaskSubject(subject) {
311
+ const value = subject.trim();
312
+ if (!value)
313
+ throw new Error("Task subject must not be empty.");
314
+ if (value.length > MAX_TASK_SUBJECT_LENGTH) {
315
+ throw new Error(`Task subject must be at most ${MAX_TASK_SUBJECT_LENGTH} characters.`);
316
+ }
317
+ return value;
318
+ }
319
+ function normalizeTaskContent(content) {
320
+ if (content.length > MAX_TASK_CONTENT_LENGTH) {
321
+ throw new Error(`Task content must be at most ${MAX_TASK_CONTENT_LENGTH} characters.`);
322
+ }
323
+ return content;
324
+ }
325
+ function normalizeInsertPosition(position, length, label) {
326
+ if (position === undefined)
327
+ return length;
328
+ if (!Number.isInteger(position) || position < 0 || position > length) {
329
+ throw new Error(`${label} position must be an integer between 0 and ${length}.`);
330
+ }
331
+ return position;
332
+ }
333
+ function normalizeMovePosition(position, length, label) {
334
+ if (!Number.isInteger(position) || position < 0 || position >= length) {
335
+ throw new Error(`${label} position must be an integer between 0 and ${Math.max(0, length - 1)}.`);
336
+ }
337
+ return position;
338
+ }
339
+ function moveArrayEntry(values, from, to) {
340
+ const [value] = values.splice(from, 1);
341
+ values.splice(to, 0, value);
342
+ }
343
+ function fingerprint(content) {
344
+ return createHash("sha256").update(content).digest("hex");
345
+ }
346
+ function isErrno(error, code) {
347
+ return error instanceof Error && "code" in error && error.code === code;
348
+ }
349
+ function errorMessage(error) {
350
+ return error instanceof Error ? error.message : String(error);
351
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
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",
@@ -47,7 +47,7 @@
47
47
  "release:publish": "node scripts/release/publish.mjs",
48
48
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
49
49
  "start": "node dist/cli.js serve",
50
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
50
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/release-version.test.mjs && tsx src/oauth/router.test.ts && tsx src/remote-auth-cli.test.ts && tsx src/remote-ssh-auth-cli.test.ts && tsx src/remote-workspace-relay.test.ts && tsx src/remote-workspace-relay-process.test.ts && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/workspace-lifecycle-app.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/operations/edit-preflight.test.ts && tsx src/skills.test.ts && tsx src/db/migrations.test.ts && tsx src/workspace-store.test.ts && tsx src/workspace-tasks.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/bash-output-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/activity/query-service.test.ts && tsx src/operations/core-operation-executor.test.ts && tsx src/operations/bulk-mutation.test.ts && tsx src/operations/batch/scheduler.test.ts && tsx src/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
51
51
  "typecheck": "tsc -p tsconfig.json --noEmit",
52
52
  "release:check": "node scripts/release-version.mjs check",
53
53
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -265,6 +265,7 @@ try {
265
265
  "hooks.lifecycle",
266
266
  "capability-guides.read",
267
267
  "code.intelligence",
268
+ "workspace.tasks",
268
269
  "batch.execute",
269
270
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
270
271
  "ui.mcp-app",
@@ -276,12 +277,55 @@ try {
276
277
  "hooks.check",
277
278
  "review.changes",
278
279
  "code.intelligence",
280
+ "workspace.tasks",
279
281
  "batch.execute",
280
282
  ...(process.platform === "linux" ? ["artifact.download"] : []),
281
283
  ]);
282
284
  assert.equal(capabilityCatalog[0].available, true);
283
285
  assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
284
286
 
287
+ const checkoutTaskStatePath = join(stateDir, "workspaces", workspaceId, "tasks.json");
288
+ assert.ok(existsSync(checkoutTaskStatePath));
289
+ const checkoutTaskList = callTool(oauth.accessToken, sessionId, 130, "capability", {
290
+ workspaceId,
291
+ name: "workspace.tasks",
292
+ action: "run",
293
+ arguments: { operation: "list.create", name: "7677 release tasks" },
294
+ });
295
+ assert.equal(checkoutTaskList.isError, undefined);
296
+ const checkoutListId = checkoutTaskList.structuredContent.result.lists[0].id;
297
+ const checkoutTask = callTool(oauth.accessToken, sessionId, 131, "capability", {
298
+ workspaceId,
299
+ name: "workspace.tasks",
300
+ action: "run",
301
+ arguments: {
302
+ operation: "task.create",
303
+ listId: checkoutListId,
304
+ subject: "Verify v0.8.3 Task persistence",
305
+ content: "created through real MCP",
306
+ status: "in_progress",
307
+ },
308
+ });
309
+ assert.equal(checkoutTask.isError, undefined);
310
+ const checkoutTaskId = checkoutTask.structuredContent.result.lists[0].tasks[0].id;
311
+ const taskFingerprintBeforeExternal = checkoutTask.structuredContent.result.fingerprint;
312
+ const externalTaskState = JSON.parse(readFileSync(checkoutTaskStatePath, "utf8"));
313
+ externalTaskState.lists[0].tasks[0].content = "reloaded external task edit";
314
+ writeFileSync(checkoutTaskStatePath, `${JSON.stringify(externalTaskState, null, 2)}\n`);
315
+ const reloadedCheckoutTasks = callTool(oauth.accessToken, sessionId, 132, "capability", {
316
+ workspaceId,
317
+ name: "workspace.tasks",
318
+ action: "run",
319
+ arguments: { operation: "get" },
320
+ });
321
+ assert.equal(reloadedCheckoutTasks.isError, undefined);
322
+ assert.equal(reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
323
+ assert.equal(
324
+ reloadedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
325
+ "reloaded external task edit",
326
+ );
327
+ assert.notEqual(reloadedCheckoutTasks.structuredContent.result.fingerprint, taskFingerprintBeforeExternal);
328
+
285
329
  const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
286
330
  path: checkoutWorkspace,
287
331
  newWorkspace: true,
@@ -312,6 +356,13 @@ try {
312
356
  assert.equal(closedWorkspace.isError, undefined);
313
357
  assert.equal(closedWorkspace.structuredContent.workspaceId, workspaceId);
314
358
  assert.equal(closedWorkspace.structuredContent.action, "close");
359
+ const closedCheckoutTasks = callTool(oauth.accessToken, sessionId, 133, "capability", {
360
+ workspaceId,
361
+ name: "workspace.tasks",
362
+ action: "run",
363
+ arguments: { operation: "get" },
364
+ });
365
+ assert.equal(closedCheckoutTasks.isError, true);
315
366
 
316
367
  const closedInventory = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
317
368
  action: "list",
@@ -336,12 +387,34 @@ try {
336
387
  resumedOriginal.structuredContent.contextFingerprint,
337
388
  opened.structuredContent.contextFingerprint,
338
389
  );
390
+ const resumedCheckoutTasks = callTool(oauth.accessToken, sessionId, 134, "capability", {
391
+ workspaceId,
392
+ name: "workspace.tasks",
393
+ action: "run",
394
+ arguments: { operation: "get" },
395
+ });
396
+ assert.equal(resumedCheckoutTasks.isError, undefined);
397
+ assert.equal(resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].id, checkoutTaskId);
398
+ assert.equal(
399
+ resumedCheckoutTasks.structuredContent.result.lists[0].tasks[0].content,
400
+ "reloaded external task edit",
401
+ );
402
+ assert.equal(resumedOriginal.structuredContent.contextFingerprint, opened.structuredContent.contextFingerprint);
339
403
 
340
404
  const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
341
405
  path: lifecycleDeleteWorkspace,
342
406
  context: "none",
343
407
  }, { "openai/session": "acceptance-workspace-delete" });
344
408
  const deleteWorkspaceId = deleteOpened.structuredContent.workspaceId;
409
+ const deleteTaskStatePath = join(stateDir, "workspaces", deleteWorkspaceId, "tasks.json");
410
+ const deleteTaskList = callTool(oauth.accessToken, sessionId, 135, "capability", {
411
+ workspaceId: deleteWorkspaceId,
412
+ name: "workspace.tasks",
413
+ action: "run",
414
+ arguments: { operation: "list.create", name: "delete with workspace" },
415
+ });
416
+ assert.equal(deleteTaskList.isError, undefined);
417
+ assert.ok(existsSync(deleteTaskStatePath));
345
418
  const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
346
419
  workspaceId: deleteWorkspaceId,
347
420
  });
@@ -353,12 +426,17 @@ try {
353
426
  assert.equal(deletedWorkspace.isError, undefined);
354
427
  assert.equal(deletedWorkspace.structuredContent.workspaceId, deleteWorkspaceId);
355
428
  assert.equal(deletedWorkspace.structuredContent.action, "delete");
429
+ assert.equal(existsSync(deleteTaskStatePath), false);
356
430
  assert.equal(readFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "utf8"), "keep checkout files\n");
357
431
  const deletedInventory = callTool(oauth.accessToken, sessionId, 94, "open_workspace", {
358
432
  action: "list",
359
433
  workspaceId: deleteWorkspaceId,
360
434
  });
361
435
  assert.equal(deletedInventory.structuredContent.workspaces.length, 0);
436
+ pass(
437
+ "workspace tasks",
438
+ `${workspaceId} create -> external reload -> close/reopen; explicit delete removed ${deleteWorkspaceId} Task state`,
439
+ );
362
440
 
363
441
  pass(
364
442
  "workspace lifecycle + inventory",
@@ -440,6 +518,7 @@ try {
440
518
  "host-integration",
441
519
  "shell-processes",
442
520
  "code-intelligence",
521
+ "workspace-tasks",
443
522
  "batch-execution",
444
523
  ]);
445
524
  const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
@@ -697,6 +776,33 @@ try {
697
776
  context: "none",
698
777
  });
699
778
  const compositeWorkspaceId = compositeOpened.structuredContent.workspaceId;
779
+ assert.deepEqual(
780
+ compositeOpened.structuredContent.capabilityCatalog.map((entry) => entry.name),
781
+ ["workspace.tasks"],
782
+ );
783
+ const compositeTaskStatePath = join(stateDir, "workspaces", compositeWorkspaceId, "tasks.json");
784
+ assert.ok(existsSync(compositeTaskStatePath));
785
+ const compositeTaskList = callTool(oauth.accessToken, sessionId, 136, "capability", {
786
+ workspaceId: compositeWorkspaceId,
787
+ name: "workspace.tasks",
788
+ action: "run",
789
+ arguments: { operation: "list.create", name: "Composite release tasks" },
790
+ });
791
+ assert.equal(compositeTaskList.isError, undefined);
792
+ const compositeTaskListId = compositeTaskList.structuredContent.result.lists[0].id;
793
+ const compositeTask = callTool(oauth.accessToken, sessionId, 137, "capability", {
794
+ workspaceId: compositeWorkspaceId,
795
+ name: "workspace.tasks",
796
+ action: "run",
797
+ arguments: {
798
+ operation: "task.create",
799
+ listId: compositeTaskListId,
800
+ subject: "Preserve Composite Task state",
801
+ content: "Composite-owned state",
802
+ },
803
+ });
804
+ assert.equal(compositeTask.isError, undefined);
805
+ const compositeTaskId = compositeTask.structuredContent.result.lists[0].tasks[0].id;
700
806
  callTool(oauth.accessToken, sessionId, 117, "open_workspace", {
701
807
  action: "member",
702
808
  workspaceId: compositeWorkspaceId,
@@ -707,6 +813,14 @@ try {
707
813
  workspaceId,
708
814
  },
709
815
  });
816
+ const memberScopedCompositeTasks = callTool(oauth.accessToken, sessionId, 138, "capability", {
817
+ workspaceId: compositeWorkspaceId,
818
+ member: "code",
819
+ name: "workspace.tasks",
820
+ action: "run",
821
+ arguments: { operation: "get" },
822
+ });
823
+ assert.equal(memberScopedCompositeTasks.isError, true);
710
824
  const closedComposite = callTool(oauth.accessToken, sessionId, 118, "close_workspace", {
711
825
  workspaceId: compositeWorkspaceId,
712
826
  });
@@ -727,6 +841,13 @@ try {
727
841
  path: "composite-sentinel.txt",
728
842
  });
729
843
  assert.equal(closedCompositeRead.isError, true);
844
+ const closedCompositeTasks = callTool(oauth.accessToken, sessionId, 139, "capability", {
845
+ workspaceId: compositeWorkspaceId,
846
+ name: "workspace.tasks",
847
+ action: "run",
848
+ arguments: { operation: "get" },
849
+ });
850
+ assert.equal(closedCompositeTasks.isError, true);
730
851
 
731
852
  const reopenedComposite = callTool(oauth.accessToken, sessionId, 121, "open_workspace", {
732
853
  workspaceId: compositeWorkspaceId,
@@ -735,6 +856,18 @@ try {
735
856
  assert.equal(reopenedComposite.structuredContent.workspaceId, compositeWorkspaceId);
736
857
  assert.equal(reopenedComposite.structuredContent.status, "active");
737
858
  assert.equal(reopenedComposite.structuredContent.members[0].workspaceId, workspaceId);
859
+ const reopenedCompositeTasks = callTool(oauth.accessToken, sessionId, 140, "capability", {
860
+ workspaceId: compositeWorkspaceId,
861
+ name: "workspace.tasks",
862
+ action: "run",
863
+ arguments: { operation: "get" },
864
+ });
865
+ assert.equal(reopenedCompositeTasks.isError, undefined);
866
+ assert.equal(reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].id, compositeTaskId);
867
+ assert.equal(
868
+ reopenedCompositeTasks.structuredContent.result.lists[0].tasks[0].content,
869
+ "Composite-owned state",
870
+ );
738
871
  const reopenedCompositeRead = callTool(oauth.accessToken, sessionId, 122, "read", {
739
872
  workspaceId: compositeWorkspaceId,
740
873
  member: "code",
@@ -748,6 +881,7 @@ try {
748
881
  });
749
882
  assert.equal(deletedComposite.structuredContent.action, "delete");
750
883
  assert.equal(deletedComposite.structuredContent.dissolved, true);
884
+ assert.equal(existsSync(compositeTaskStatePath), false);
751
885
  const memberAfterCompositeDelete = callTool(oauth.accessToken, sessionId, 124, "read", {
752
886
  workspaceId,
753
887
  path: "composite-sentinel.txt",
@@ -759,6 +893,10 @@ try {
759
893
  workspaceId: compositeWorkspaceId,
760
894
  });
761
895
  assert.equal(deletedCompositeInventory.structuredContent.compositeWorkspaces.length, 0);
896
+ pass(
897
+ "Composite workspace tasks",
898
+ `${compositeWorkspaceId} self-owned Task state -> close/reopen -> delete cleanup; member-scoped Task access rejected`,
899
+ );
762
900
  pass(
763
901
  "Composite lifecycle",
764
902
  `${compositeWorkspaceId} close -> closed/non-routable -> same-id reopen -> delete; member Workspace preserved`,