@akira-tl/forgerelay 0.8.2 → 0.8.4
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 +16 -0
- package/capabilities/workspace-tasks/GUIDE.md +50 -0
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +96 -21
- package/dist/config.js +11 -0
- package/dist/remote-workspace-relay.js +15 -0
- package/dist/server.js +449 -34
- package/dist/subagents/sessions/capability.js +3 -0
- package/dist/workspace-task-reminders.js +42 -0
- package/dist/workspace-tasks.js +420 -0
- package/dist/workspaces.js +62 -30
- package/docs/chatgpt-coding-workflow.md +13 -3
- package/docs/configuration.md +46 -11
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +298 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.4] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `open_workspace(action="inspect", workspaceId=...)` for bounded read-only Workspace metadata, including safe Task summaries without opening, rebinding, or exposing bootstrap/remote-auth details.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `workspace.tasks` now discloses Task state progressively from List summaries to headers and one Task detail, with configurable forgotten-update reminders after meaningful Workspace work.
|
|
16
|
+
|
|
17
|
+
## [0.8.3] - 2026-08-31
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
7
23
|
## [0.8.2] - 2026-08-31
|
|
8
24
|
|
|
9
25
|
### Changed
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
Task 读取默认使用渐进式披露,不会一次返回所有 `content`:
|
|
16
|
+
|
|
17
|
+
- `operation="get"` 或 `level="summary"`:返回 List identity、状态、revision、Task 数量和未完成数量,不返回 Task headers/body。
|
|
18
|
+
- `operation="get", level="headers"`:返回 Task `id`、`status`、`subject`,不返回 `content`;可传 `listId` 只看一个 List。
|
|
19
|
+
- `operation="get", level="detail", listId=..., taskId=...`:只返回指定 Task 的完整 `content`。
|
|
20
|
+
|
|
21
|
+
先从 summary 定位 List,再按需读取 headers/detail;不要把所有 Task body 当作默认上下文。
|
|
22
|
+
|
|
23
|
+
## 修改操作
|
|
24
|
+
|
|
25
|
+
修改响应同样保持有界:List 修改返回 summary;Task 修改返回对应 List 的 headers,不会顺带回传所有 Task body。
|
|
26
|
+
|
|
27
|
+
List 操作:
|
|
28
|
+
|
|
29
|
+
- `list.create`:创建具名 List,可指定 `position`。
|
|
30
|
+
- `list.update`:修改 `name`、`state` 或 `position`;`state="archived"` 用于归档,改回 `active` 即重新激活。
|
|
31
|
+
- `list.delete`:显式删除 List 及其 Tasks。
|
|
32
|
+
|
|
33
|
+
Task 操作:
|
|
34
|
+
|
|
35
|
+
- `task.create`:在一个 List 中创建 Task;需要 `subject`,可提供 `content`、`status`、`position`。
|
|
36
|
+
- `task.update`:修改 `subject`、`content`、`status` 或 `position`;至少提供一个变更字段。
|
|
37
|
+
- `task.delete`:显式删除 Task。
|
|
38
|
+
|
|
39
|
+
Task 修改使用独立的 revision/fingerprint 域;它不改变 Workspace bootstrap `contextFingerprint`。
|
|
40
|
+
|
|
41
|
+
## 忘记更新提醒
|
|
42
|
+
|
|
43
|
+
ForgeRelay 会按当前 Workspace 统计成功的语义工作调用。默认连续 30 次语义工作没有 Task mutation、且仍存在 active List 中的未完成 Task 时,在工作结果后追加一条简短提醒;提醒只提示检查 `workspace.tasks`,不会携带 Task `content`。
|
|
44
|
+
|
|
45
|
+
- 任意 List/Task create/update/delete 都会重置计数。
|
|
46
|
+
- `open_workspace` inventory/open/close、Activity/UI 查询、Capability describe、Task get,以及 `bash action="process"|"output"` / Codex `write_stdin` 等已有进程 follow-up 不单独计数。
|
|
47
|
+
- `batch.execute` 作为一次顶层语义工作调用计数,而不是按 child operation 重复计数。
|
|
48
|
+
- 只有 active List 中仍有 `pending` / `in_progress` Task 才会提醒;归档 List 或全部完成会 suppress 提醒。
|
|
49
|
+
- 计数器只保存在当前 ForgeRelay 进程内,server restart 可以重置;Task durable state 本身不受影响。
|
|
50
|
+
- `FORGERELAY_TASK_REMINDER_INTERVAL=0` 可禁用提醒;其他非负整数设置间隔。
|
package/dist/capabilities.js
CHANGED
|
@@ -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,66 @@ 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({
|
|
137
|
+
operation: z.literal("get"),
|
|
138
|
+
level: z.literal("summary").optional(),
|
|
139
|
+
}).strict(),
|
|
140
|
+
z.object({
|
|
141
|
+
operation: z.literal("get"),
|
|
142
|
+
level: z.literal("headers"),
|
|
143
|
+
listId: z.string().min(1).optional(),
|
|
144
|
+
}).strict(),
|
|
145
|
+
z.object({
|
|
146
|
+
operation: z.literal("get"),
|
|
147
|
+
level: z.literal("detail"),
|
|
148
|
+
listId: z.string().min(1),
|
|
149
|
+
taskId: z.string().min(1),
|
|
150
|
+
}).strict(),
|
|
151
|
+
z.object({
|
|
152
|
+
operation: z.literal("list.create"),
|
|
153
|
+
name: z.string().trim().min(1),
|
|
154
|
+
position: z.number().int().min(0).optional(),
|
|
155
|
+
}).strict(),
|
|
156
|
+
z.object({
|
|
157
|
+
operation: z.literal("list.update"),
|
|
158
|
+
listId: z.string().min(1),
|
|
159
|
+
name: z.string().trim().min(1).optional(),
|
|
160
|
+
state: workspaceTaskListState.optional(),
|
|
161
|
+
position: z.number().int().min(0).optional(),
|
|
162
|
+
}).strict().refine((input) => input.name !== undefined || input.state !== undefined || input.position !== undefined, { message: "list.update requires at least one field to change" }),
|
|
163
|
+
z.object({
|
|
164
|
+
operation: z.literal("list.delete"),
|
|
165
|
+
listId: z.string().min(1),
|
|
166
|
+
}).strict(),
|
|
167
|
+
z.object({
|
|
168
|
+
operation: z.literal("task.create"),
|
|
169
|
+
listId: z.string().min(1),
|
|
170
|
+
subject: z.string().trim().min(1),
|
|
171
|
+
content: z.string().optional(),
|
|
172
|
+
status: workspaceTaskStatus.optional(),
|
|
173
|
+
position: z.number().int().min(0).optional(),
|
|
174
|
+
}).strict(),
|
|
175
|
+
z.object({
|
|
176
|
+
operation: z.literal("task.update"),
|
|
177
|
+
listId: z.string().min(1),
|
|
178
|
+
taskId: z.string().min(1),
|
|
179
|
+
subject: z.string().trim().min(1).optional(),
|
|
180
|
+
content: z.string().optional(),
|
|
181
|
+
status: workspaceTaskStatus.optional(),
|
|
182
|
+
position: z.number().int().min(0).optional(),
|
|
183
|
+
}).strict().refine((input) => input.subject !== undefined
|
|
184
|
+
|| input.content !== undefined
|
|
185
|
+
|| input.status !== undefined
|
|
186
|
+
|| input.position !== undefined, { message: "task.update requires at least one field to change" }),
|
|
187
|
+
z.object({
|
|
188
|
+
operation: z.literal("task.delete"),
|
|
189
|
+
listId: z.string().min(1),
|
|
190
|
+
taskId: z.string().min(1),
|
|
191
|
+
}).strict(),
|
|
192
|
+
]);
|
|
133
193
|
const subagentSessionInput = z.discriminatedUnion("operation", [
|
|
134
194
|
z.object({
|
|
135
195
|
operation: z.literal("start"),
|
|
@@ -190,11 +250,11 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
190
250
|
readGuideBeforeFirstUse: true,
|
|
191
251
|
batchPolicy: "parallel",
|
|
192
252
|
inputSchema: hooksCheckInput,
|
|
193
|
-
availability: () => (
|
|
253
|
+
availability: (context) => filesystemWorkspaceAvailability(context),
|
|
194
254
|
run: async (_input, context) => ({
|
|
195
255
|
value: {
|
|
196
256
|
ok: true,
|
|
197
|
-
...await dependencies.inspectHooks(context
|
|
257
|
+
...await dependencies.inspectHooks(requireWorkspaceRoot(context)),
|
|
198
258
|
},
|
|
199
259
|
}),
|
|
200
260
|
},
|
|
@@ -206,10 +266,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
206
266
|
readGuideBeforeFirstUse: true,
|
|
207
267
|
batchPolicy: "serial",
|
|
208
268
|
inputSchema: z.object({}).strict(),
|
|
209
|
-
availability: () => (
|
|
210
|
-
available: dependencies.reviewChanges?.available ?? false,
|
|
211
|
-
reason: dependencies.reviewChanges?.unavailableReason,
|
|
212
|
-
}),
|
|
269
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.reviewChanges?.available ?? false, dependencies.reviewChanges?.unavailableReason),
|
|
213
270
|
run: async (_input, context) => dependencies.reviewChanges.run(context),
|
|
214
271
|
}]
|
|
215
272
|
: []),
|
|
@@ -221,11 +278,23 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
221
278
|
readGuideBeforeFirstUse: true,
|
|
222
279
|
batchPolicy: "parallel",
|
|
223
280
|
inputSchema: codeIntelligenceInput,
|
|
281
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.codeIntelligence?.available ?? false, dependencies.codeIntelligence?.unavailableReason),
|
|
282
|
+
run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
|
|
283
|
+
}]
|
|
284
|
+
: []),
|
|
285
|
+
...(dependencies.workspaceTasks
|
|
286
|
+
? [{
|
|
287
|
+
name: "workspace.tasks",
|
|
288
|
+
description: "Maintain persistent Task Lists owned by the current Workspace.",
|
|
289
|
+
guideName: "workspace-tasks",
|
|
290
|
+
readGuideBeforeFirstUse: true,
|
|
291
|
+
batchPolicy: "serial",
|
|
292
|
+
inputSchema: workspaceTasksInput,
|
|
224
293
|
availability: () => ({
|
|
225
|
-
available: dependencies.
|
|
226
|
-
reason: dependencies.
|
|
294
|
+
available: dependencies.workspaceTasks?.available ?? false,
|
|
295
|
+
reason: dependencies.workspaceTasks?.unavailableReason,
|
|
227
296
|
}),
|
|
228
|
-
run: async (input, context, options) => dependencies.
|
|
297
|
+
run: async (input, context, options) => dependencies.workspaceTasks.run(input, context, options),
|
|
229
298
|
}]
|
|
230
299
|
: []),
|
|
231
300
|
...(dependencies.subagentSession
|
|
@@ -236,10 +305,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
236
305
|
readGuideBeforeFirstUse: true,
|
|
237
306
|
batchPolicy: "unsupported",
|
|
238
307
|
inputSchema: subagentSessionInput,
|
|
239
|
-
availability: () => (
|
|
240
|
-
available: dependencies.subagentSession?.available ?? false,
|
|
241
|
-
reason: dependencies.subagentSession?.unavailableReason,
|
|
242
|
-
}),
|
|
308
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.subagentSession?.available ?? false, dependencies.subagentSession?.unavailableReason),
|
|
243
309
|
run: async (input, context, options) => dependencies.subagentSession.run(input, context, options),
|
|
244
310
|
}]
|
|
245
311
|
: []),
|
|
@@ -251,10 +317,7 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
251
317
|
readGuideBeforeFirstUse: true,
|
|
252
318
|
batchPolicy: "unsupported",
|
|
253
319
|
inputSchema: batchExecuteInputSchema,
|
|
254
|
-
availability: () => (
|
|
255
|
-
available: dependencies.batchExecute?.available ?? false,
|
|
256
|
-
reason: dependencies.batchExecute?.unavailableReason,
|
|
257
|
-
}),
|
|
320
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.batchExecute?.available ?? false, dependencies.batchExecute?.unavailableReason),
|
|
258
321
|
run: async (input, context, options) => dependencies.batchExecute.run(input, context, options),
|
|
259
322
|
}]
|
|
260
323
|
: []),
|
|
@@ -277,15 +340,27 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
277
340
|
path: z.string().min(1),
|
|
278
341
|
}).strict(),
|
|
279
342
|
nativeFileArgument: "file",
|
|
280
|
-
availability: () => (
|
|
281
|
-
available: dependencies.downloadArtifact?.available ?? false,
|
|
282
|
-
reason: dependencies.downloadArtifact?.unavailableReason,
|
|
283
|
-
}),
|
|
343
|
+
availability: (context) => filesystemWorkspaceAvailability(context, dependencies.downloadArtifact?.available ?? false, dependencies.downloadArtifact?.unavailableReason),
|
|
284
344
|
run: async (input, context) => dependencies.downloadArtifact.run(input, context),
|
|
285
345
|
}]
|
|
286
346
|
: []),
|
|
287
347
|
]);
|
|
288
348
|
}
|
|
349
|
+
function filesystemWorkspaceAvailability(context, available = true, reason) {
|
|
350
|
+
if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
|
|
351
|
+
return {
|
|
352
|
+
available: false,
|
|
353
|
+
reason: "This capability requires a filesystem-backed Workspace.",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return { available, ...(reason ? { reason } : {}) };
|
|
357
|
+
}
|
|
358
|
+
function requireWorkspaceRoot(context) {
|
|
359
|
+
if (context.workspaceKind !== "workspace" || !context.workspaceRoot) {
|
|
360
|
+
throw new CapabilityError("capability_unavailable", "This capability requires a filesystem-backed Workspace.");
|
|
361
|
+
}
|
|
362
|
+
return context.workspaceRoot;
|
|
363
|
+
}
|
|
289
364
|
function isRecord(value) {
|
|
290
365
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
291
366
|
}
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,7 @@ import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForge
|
|
|
7
7
|
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
8
8
|
const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
9
9
|
const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
const DEFAULT_TASK_REMINDER_INTERVAL = 30;
|
|
10
11
|
function parsePort(value) {
|
|
11
12
|
if (value === undefined || value === "")
|
|
12
13
|
return 7676;
|
|
@@ -109,6 +110,15 @@ function parsePositiveInteger(value, fallback, name, max = Number.MAX_SAFE_INTEG
|
|
|
109
110
|
}
|
|
110
111
|
return parsed;
|
|
111
112
|
}
|
|
113
|
+
function parseNonNegativeInteger(value, fallback, name) {
|
|
114
|
+
if (value === undefined || value === "")
|
|
115
|
+
return fallback;
|
|
116
|
+
const parsed = Number(value);
|
|
117
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
118
|
+
throw new Error(`Invalid ${name}: ${value}`);
|
|
119
|
+
}
|
|
120
|
+
return parsed;
|
|
121
|
+
}
|
|
112
122
|
function parseLoggingConfig(env, trustProxyDefault) {
|
|
113
123
|
const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
|
|
114
124
|
const requests = productEnv(env, "LOG_REQUESTS");
|
|
@@ -247,6 +257,7 @@ export function loadConfig(env = process.env) {
|
|
|
247
257
|
? files.config.artifactsEnabled === true
|
|
248
258
|
: parseBoolean(productEnv(env, "ARTIFACTS")),
|
|
249
259
|
artifactMaxFileBytes: parsePositiveInteger(productEnv(env, "ARTIFACT_MAX_FILE_BYTES") ?? numberConfigValue(files.config.artifactMaxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "FORGERELAY_ARTIFACT_MAX_FILE_BYTES"),
|
|
260
|
+
taskReminderInterval: parseNonNegativeInteger(productEnv(env, "TASK_REMINDER_INTERVAL") ?? numberConfigValue(files.config.taskReminderInterval), DEFAULT_TASK_REMINDER_INTERVAL, "FORGERELAY_TASK_REMINDER_INTERVAL"),
|
|
250
261
|
skillsEnabled: productEnv(env, "SKILLS") === undefined ? true : parseBoolean(productEnv(env, "SKILLS")),
|
|
251
262
|
skillPaths: parsePathList(productEnv(env, "SKILL_PATHS")),
|
|
252
263
|
devspaceSkillsDir: forgerelaySkillsDir(env),
|
|
@@ -27,6 +27,21 @@ export class RemoteWorkspaceRelay {
|
|
|
27
27
|
this.loadRoutes();
|
|
28
28
|
return this.routes.has(workspaceId);
|
|
29
29
|
}
|
|
30
|
+
inspectWorkspace(gatewayWorkspaceId) {
|
|
31
|
+
const route = this.requireRoute(gatewayWorkspaceId);
|
|
32
|
+
const resolved = this.remoteByInstance(route.remoteInstanceId);
|
|
33
|
+
return {
|
|
34
|
+
workspaceId: route.gatewayWorkspaceId,
|
|
35
|
+
kind: "workspace",
|
|
36
|
+
location: "relay",
|
|
37
|
+
root: route.root,
|
|
38
|
+
routeState: "known",
|
|
39
|
+
mode: route.mode,
|
|
40
|
+
...(route.sourceRoot ? { sourceRoot: route.sourceRoot } : {}),
|
|
41
|
+
relay: resolved.alias,
|
|
42
|
+
executionLocation: `remote:${resolved.alias}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
30
45
|
async openWorkspace(alias, input, conversationScopeId) {
|
|
31
46
|
const resolved = this.remoteByAlias(alias);
|
|
32
47
|
let result;
|