@akira-tl/forgerelay 0.7.4 → 0.8.1
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 +21 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/schema.js +8 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +111 -20
- package/dist/workspace-store.js +77 -8
- package/dist/workspaces.js +153 -87
- package/docs/chatgpt-coding-workflow.md +41 -34
- package/docs/configuration.md +38 -32
- package/docs/roadmap.md +39 -11
- package/docs/security.md +4 -4
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +73 -31
package/docs/roadmap.md
CHANGED
|
@@ -284,6 +284,34 @@ Git state, processes, Hooks, Skills, Language services, Activity, and Audit fact
|
|
|
284
284
|
Workspace Relay is not file synchronization or failover, and Composite Workspace
|
|
285
285
|
never silently chooses a member.
|
|
286
286
|
|
|
287
|
+
## 0.8 — Persistent Workspace lifecycle and Task Lists
|
|
288
|
+
|
|
289
|
+
0.8 turns Workspace identity from a disposable logical handle into a persistent
|
|
290
|
+
ForgeRelay work boundary, then layers lightweight Workspace-owned Task Lists and
|
|
291
|
+
safe cross-Workspace inspection on top. Development is release-gated: each stage
|
|
292
|
+
must be published successfully before work begins on the next stage.
|
|
293
|
+
|
|
294
|
+
- **0.8.0** — fix Composite bootstrap correctness and establish one canonical,
|
|
295
|
+
persistent Workspace identity per physical checkout/worktree target, including
|
|
296
|
+
migration compatibility for legacy aliases;
|
|
297
|
+
- **0.8.1** — make checkout close non-destructive and reversible, add explicit
|
|
298
|
+
Workspace deletion, and prevent GC from deleting persistent Workspace identity;
|
|
299
|
+
- **0.8.2** — carry the persistent lifecycle through managed worktrees and Composite
|
|
300
|
+
Workspaces, preserving state across close/reopen while keeping safe worktree
|
|
301
|
+
finalize semantics;
|
|
302
|
+
- **0.8.3** — add versioned file-backed Task Lists in ForgeRelay-owned Workspace
|
|
303
|
+
state, with mutations scoped to the current Workspace;
|
|
304
|
+
- **0.8.4** — add progressive Task disclosure, forgotten-update reminders, and
|
|
305
|
+
allowlist-based read-only inspection of other Workspaces and their safe Task
|
|
306
|
+
projections;
|
|
307
|
+
- **0.8.5** — verify the complete contract across Workspace Relay and publish the
|
|
308
|
+
accepted 0.8 lifecycle/Task model.
|
|
309
|
+
|
|
310
|
+
The release boundary is part of the dependency graph, not just a documentation
|
|
311
|
+
milestone: the next stage remains blocked until the previous version's tag-triggered
|
|
312
|
+
release workflow has completed successfully. Runtime acceptance uses only the
|
|
313
|
+
reserved 7677/7678 debug instances and never touches the normal 7676 installation.
|
|
314
|
+
|
|
287
315
|
## Later: first-class subagent MCP
|
|
288
316
|
|
|
289
317
|
ForgeRelay already owns provider adapters and resumable local agent sessions. A
|
|
@@ -311,20 +339,20 @@ are ready:
|
|
|
311
339
|
Checkpoint restore must protect concurrent/external user edits rather than
|
|
312
340
|
blindly overwriting a working tree.
|
|
313
341
|
|
|
314
|
-
##
|
|
342
|
+
## Workspace Task Lists
|
|
315
343
|
|
|
316
|
-
|
|
317
|
-
|
|
344
|
+
ForgeRelay may provide persistent, lightweight Task Lists owned by a Workspace for
|
|
345
|
+
work that should survive Host Turns, conversation changes, and Workspace close.
|
|
346
|
+
Task is a checklist/continuation primitive rather than an execution scheduler:
|
|
318
347
|
|
|
319
|
-
-
|
|
320
|
-
-
|
|
321
|
-
-
|
|
322
|
-
-
|
|
323
|
-
-
|
|
348
|
+
- one Workspace may own multiple named Task Lists;
|
|
349
|
+
- a Task records a work requirement, lightweight progress state, and Agent-managed continuation text;
|
|
350
|
+
- Task does not imply a Queue, Goal object, dependency graph, Subagent assignment, Run binding, or autonomous execution policy;
|
|
351
|
+
- current-Workspace Task state may be mutated through a Capability, while information about another Workspace, including Task projections, is exposed only through the read-only Workspace inspection path;
|
|
352
|
+
- Task state is durable Workspace-owned local state and is not deleted by ordinary Workspace close or GC.
|
|
324
353
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
insufficient.
|
|
354
|
+
Subagent coordination can reference Task content when useful, but ForgeRelay does
|
|
355
|
+
not bind Tasks to Subagent Sessions or infer Task completion from execution facts.
|
|
328
356
|
|
|
329
357
|
## Compatibility policy
|
|
330
358
|
|
package/docs/security.md
CHANGED
|
@@ -144,10 +144,10 @@ process lifetime. When `bash` is still running after that window, ForgeRelay
|
|
|
144
144
|
returns a canonical `processId` and leaves the process alive. Regular tool modes
|
|
145
145
|
reuse `bash(action="process")` to poll, wait, write input, resize a PTY, or
|
|
146
146
|
explicitly interrupt that process. An asynchronously completed process
|
|
147
|
-
is reported on a later tool result for the same
|
|
148
|
-
error-result paths, and is never broadcast to another
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
is reported on a later tool result for the same persistent Workspace ID, including
|
|
148
|
+
error-result paths, and is never broadcast to another Workspace ID. Reusing that
|
|
149
|
+
Workspace from another conversation intentionally shares the same completion scope
|
|
150
|
+
as well. Hook handlers keep their separate bounded timeout
|
|
151
151
|
policy because they are lifecycle gates rather than user-command execution.
|
|
152
152
|
|
|
153
153
|
## Lifecycle hooks
|
package/package.json
CHANGED
package/scripts/debug/accept.mjs
CHANGED
|
@@ -32,6 +32,7 @@ const stateDir = resolve(acceptanceRoot, "state");
|
|
|
32
32
|
const worktreeRoot = resolve(acceptanceRoot, "worktrees");
|
|
33
33
|
const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
|
|
34
34
|
const checkoutWorkspace = resolve(acceptanceRoot, "workspace");
|
|
35
|
+
const lifecycleDeleteWorkspace = resolve(acceptanceRoot, "delete-workspace");
|
|
35
36
|
const codeIntelligenceLog = resolve(acceptanceRoot, "code-intelligence-lsp.jsonl");
|
|
36
37
|
const fakeLanguageServer = resolve(repoRoot, "src", "lsp", "test-fixtures", "fake-lsp-server.mjs");
|
|
37
38
|
const gitProject = resolve(acceptanceRoot, "git-project");
|
|
@@ -45,6 +46,8 @@ await assertDebugPortFree();
|
|
|
45
46
|
rmSync(acceptanceRoot, { recursive: true, force: true });
|
|
46
47
|
mkdirSync(acceptanceRoot, { recursive: true });
|
|
47
48
|
setupGitProject(checkoutWorkspace);
|
|
49
|
+
setupGitProject(lifecycleDeleteWorkspace);
|
|
50
|
+
writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
|
|
48
51
|
setupCodeIntelligenceAcceptanceProject({
|
|
49
52
|
root: checkoutWorkspace,
|
|
50
53
|
fakeLanguageServer,
|
|
@@ -279,42 +282,52 @@ try {
|
|
|
279
282
|
assert.equal(capabilityCatalog[0].available, true);
|
|
280
283
|
assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
|
|
281
284
|
|
|
282
|
-
const
|
|
285
|
+
const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
|
|
283
286
|
path: checkoutWorkspace,
|
|
284
287
|
newWorkspace: true,
|
|
285
288
|
}, workspaceConversationMeta);
|
|
286
|
-
|
|
287
|
-
assert.
|
|
288
|
-
assert.equal(freshLogical.structuredContent.action, "open");
|
|
289
|
+
assert.equal(repeatedOpen.structuredContent.workspaceId, workspaceId);
|
|
290
|
+
assert.equal(repeatedOpen.structuredContent.action, "open");
|
|
289
291
|
assert.equal(
|
|
290
|
-
|
|
292
|
+
repeatedOpen.structuredContent.contextFingerprint,
|
|
291
293
|
opened.structuredContent.contextFingerprint,
|
|
292
294
|
);
|
|
293
|
-
assert.equal(
|
|
294
|
-
assert.equal(
|
|
295
|
+
assert.equal(repeatedOpen.structuredContent.agentsFiles, undefined);
|
|
296
|
+
assert.equal(repeatedOpen.structuredContent.capabilityGuides, undefined);
|
|
295
297
|
|
|
296
298
|
const workspaceInventory = callTool(oauth.accessToken, sessionId, 85, "open_workspace", {
|
|
297
299
|
action: "list",
|
|
298
300
|
root: checkoutWorkspace,
|
|
299
301
|
}, workspaceConversationMeta);
|
|
300
302
|
assert.equal(workspaceInventory.structuredContent.action, "list");
|
|
301
|
-
assert.equal(workspaceInventory.structuredContent.summary.matching,
|
|
303
|
+
assert.equal(workspaceInventory.structuredContent.summary.matching, 1);
|
|
302
304
|
const inventoryEntries = workspaceInventory.structuredContent.workspaces;
|
|
303
|
-
assert.equal(inventoryEntries.length,
|
|
304
|
-
assert.equal(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
305
|
+
assert.equal(inventoryEntries.length, 1);
|
|
306
|
+
assert.equal(inventoryEntries[0].workspaceId, workspaceId);
|
|
307
|
+
assert.equal(inventoryEntries[0].current, true);
|
|
308
|
+
|
|
309
|
+
const closedWorkspace = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
|
|
310
|
+
workspaceId,
|
|
311
|
+
});
|
|
312
|
+
assert.equal(closedWorkspace.isError, undefined);
|
|
313
|
+
assert.equal(closedWorkspace.structuredContent.workspaceId, workspaceId);
|
|
314
|
+
assert.equal(closedWorkspace.structuredContent.action, "close");
|
|
315
|
+
|
|
316
|
+
const closedInventory = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
|
|
317
|
+
action: "list",
|
|
318
|
+
workspaceId,
|
|
319
|
+
}, workspaceConversationMeta);
|
|
320
|
+
assert.equal(closedInventory.structuredContent.workspaces.length, 1);
|
|
321
|
+
assert.equal(closedInventory.structuredContent.workspaces[0].state, "closed");
|
|
322
|
+
assert.equal(closedInventory.structuredContent.workspaces[0].current, false);
|
|
312
323
|
|
|
313
|
-
const
|
|
314
|
-
workspaceId
|
|
324
|
+
const closedRead = callTool(oauth.accessToken, sessionId, 89, "read", {
|
|
325
|
+
workspaceId,
|
|
326
|
+
path: "AGENTS.md",
|
|
315
327
|
});
|
|
316
|
-
assert.equal(
|
|
317
|
-
|
|
328
|
+
assert.equal(closedRead.isError, true);
|
|
329
|
+
|
|
330
|
+
const resumedOriginal = callTool(oauth.accessToken, sessionId, 90, "open_workspace", {
|
|
318
331
|
workspaceId,
|
|
319
332
|
}, workspaceConversationMeta);
|
|
320
333
|
assert.equal(resumedOriginal.structuredContent.workspaceId, workspaceId);
|
|
@@ -323,9 +336,33 @@ try {
|
|
|
323
336
|
resumedOriginal.structuredContent.contextFingerprint,
|
|
324
337
|
opened.structuredContent.contextFingerprint,
|
|
325
338
|
);
|
|
339
|
+
|
|
340
|
+
const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
|
|
341
|
+
path: lifecycleDeleteWorkspace,
|
|
342
|
+
context: "none",
|
|
343
|
+
}, { "openai/session": "acceptance-workspace-delete" });
|
|
344
|
+
const deleteWorkspaceId = deleteOpened.structuredContent.workspaceId;
|
|
345
|
+
const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
|
|
346
|
+
workspaceId: deleteWorkspaceId,
|
|
347
|
+
});
|
|
348
|
+
assert.equal(deleteClosed.structuredContent.action, "close");
|
|
349
|
+
const deletedWorkspace = callTool(oauth.accessToken, sessionId, 93, "close_workspace", {
|
|
350
|
+
workspaceId: deleteWorkspaceId,
|
|
351
|
+
action: "delete",
|
|
352
|
+
});
|
|
353
|
+
assert.equal(deletedWorkspace.isError, undefined);
|
|
354
|
+
assert.equal(deletedWorkspace.structuredContent.workspaceId, deleteWorkspaceId);
|
|
355
|
+
assert.equal(deletedWorkspace.structuredContent.action, "delete");
|
|
356
|
+
assert.equal(readFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "utf8"), "keep checkout files\n");
|
|
357
|
+
const deletedInventory = callTool(oauth.accessToken, sessionId, 94, "open_workspace", {
|
|
358
|
+
action: "list",
|
|
359
|
+
workspaceId: deleteWorkspaceId,
|
|
360
|
+
});
|
|
361
|
+
assert.equal(deletedInventory.structuredContent.workspaces.length, 0);
|
|
362
|
+
|
|
326
363
|
pass(
|
|
327
|
-
"workspace
|
|
328
|
-
`${workspaceId} ->
|
|
364
|
+
"workspace lifecycle + inventory",
|
|
365
|
+
`${workspaceId} canonical reuse -> close -> list -> reopen; explicit delete preserves checkout files`,
|
|
329
366
|
);
|
|
330
367
|
|
|
331
368
|
const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
|
|
@@ -972,13 +1009,14 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
972
1009
|
|
|
973
1010
|
function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspaceRoot) {
|
|
974
1011
|
const seedScript = [
|
|
975
|
-
'import {
|
|
976
|
-
'const store = new
|
|
1012
|
+
'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
|
|
1013
|
+
'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
|
|
977
1014
|
'const record = store.create({',
|
|
978
1015
|
` workspaceId: ${JSON.stringify(workspaceId)},`,
|
|
979
1016
|
` workspaceRoot: ${JSON.stringify(workspaceRoot)},`,
|
|
980
1017
|
' profileName: "debug-missing-profile",',
|
|
981
1018
|
' provider: "codex",',
|
|
1019
|
+
' activeRun: { id: "run_debug_acceptance", startedAt: new Date().toISOString() },',
|
|
982
1020
|
'});',
|
|
983
1021
|
'store.close();',
|
|
984
1022
|
'process.stdout.write(record.id);',
|
|
@@ -1006,11 +1044,13 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
|
|
|
1006
1044
|
}
|
|
1007
1045
|
|
|
1008
1046
|
const inspectScript = [
|
|
1009
|
-
'import {
|
|
1010
|
-
'
|
|
1047
|
+
'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
|
|
1048
|
+
'import { SubagentDeliveryMailbox } from "./src/subagents/sessions/delivery-mailbox.js";',
|
|
1049
|
+
'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
|
|
1011
1050
|
`const record = store.get(${JSON.stringify(agentId)});`,
|
|
1051
|
+
`const deliveries = new SubagentDeliveryMailbox(process.env.FORGERELAY_STATE_DIR).claimSession(${JSON.stringify(workspaceId)}, ${JSON.stringify(agentId)});`,
|
|
1012
1052
|
'store.close();',
|
|
1013
|
-
'process.stdout.write(JSON.stringify({ status: record?.status,
|
|
1053
|
+
'process.stdout.write(JSON.stringify({ status: record?.status, latestRun: record?.latestRun, delivery: deliveries[0] }));',
|
|
1014
1054
|
].join("\n");
|
|
1015
1055
|
const inspected = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", inspectScript], {
|
|
1016
1056
|
cwd: repoRoot,
|
|
@@ -1021,9 +1061,11 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
|
|
|
1021
1061
|
throw new Error(`unable to inspect debug subagent: ${inspected.stderr.trim()}`);
|
|
1022
1062
|
}
|
|
1023
1063
|
const record = JSON.parse(inspected.stdout);
|
|
1024
|
-
assert.equal(record.status, "
|
|
1025
|
-
assert.
|
|
1026
|
-
|
|
1064
|
+
assert.equal(record.status, "idle");
|
|
1065
|
+
assert.equal(record.latestRun?.status, "failed");
|
|
1066
|
+
assert.equal(record.delivery?.outcome, "failed");
|
|
1067
|
+
assert.match(record.delivery?.error ?? "", /Subagent profile not found/);
|
|
1068
|
+
pass("subagent hook path", `${agentId} stopped in deterministic failed Run without calling a provider`);
|
|
1027
1069
|
}
|
|
1028
1070
|
|
|
1029
1071
|
function toolText(result) {
|