@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/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
- ## Later: task orchestration
342
+ ## Workspace Task Lists
315
343
 
316
- After first-class subagents are stable, ForgeRelay may add a small persistent
317
- task graph for parent/worker coordination:
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
- - task identity/title/status;
320
- - dependencies;
321
- - assigned local-agent session;
322
- - workspace/worktree association;
323
- - structured result/error state.
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
- Start with DAG-style parent/child orchestration. Peer-to-peer agent-team messaging
326
- should only be added if real workflows demonstrate that the simpler task model is
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 logical workspace ID, including
148
- error-result paths, and is never broadcast to another workspace ID. Explicitly
149
- resuming the same workspace ID in another conversation intentionally transfers
150
- that completion scope as well. Hook handlers keep their separate bounded timeout
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
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",
@@ -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 freshLogical = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
285
+ const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
283
286
  path: checkoutWorkspace,
284
287
  newWorkspace: true,
285
288
  }, workspaceConversationMeta);
286
- const freshLogicalId = freshLogical.structuredContent.workspaceId;
287
- assert.notEqual(freshLogicalId, workspaceId);
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
- freshLogical.structuredContent.contextFingerprint,
292
+ repeatedOpen.structuredContent.contextFingerprint,
291
293
  opened.structuredContent.contextFingerprint,
292
294
  );
293
- assert.equal(freshLogical.structuredContent.agentsFiles, undefined);
294
- assert.equal(freshLogical.structuredContent.capabilityGuides, undefined);
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, 2);
303
+ assert.equal(workspaceInventory.structuredContent.summary.matching, 1);
302
304
  const inventoryEntries = workspaceInventory.structuredContent.workspaces;
303
- assert.equal(inventoryEntries.length, 2);
304
- assert.equal(
305
- inventoryEntries.find((entry) => entry.workspaceId === freshLogicalId)?.current,
306
- true,
307
- );
308
- assert.equal(
309
- inventoryEntries.find((entry) => entry.workspaceId === workspaceId)?.current,
310
- false,
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 closedFreshLogical = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
314
- workspaceId: freshLogicalId,
324
+ const closedRead = callTool(oauth.accessToken, sessionId, 89, "read", {
325
+ workspaceId,
326
+ path: "AGENTS.md",
315
327
  });
316
- assert.equal(closedFreshLogical.isError, undefined);
317
- const resumedOriginal = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
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 context + inventory",
328
- `${workspaceId} -> ${freshLogicalId} -> list -> close -> resume without bootstrap replay`,
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 { LocalAgentStore } from "./src/local-agent-store.js";',
976
- 'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
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 { LocalAgentStore } from "./src/local-agent-store.js";',
1010
- 'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
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, error: record?.error }));',
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, "error");
1025
- assert.match(record.error, /Subagent profile not found/);
1026
- pass("subagent hook path", `${agentId} stopped in deterministic error path without calling a provider`);
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) {