@akira-tl/forgerelay 0.3.4 → 0.3.6

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/capabilities/artifacts-review/GUIDE.md +2 -4
  3. package/dist/artifact-tools.js +0 -128
  4. package/dist/capabilities.js +2 -5
  5. package/dist/db/migrations.js +19 -0
  6. package/dist/db/schema.js +9 -0
  7. package/dist/hooks.js +12 -0
  8. package/dist/mcp/server-instructions.js +4 -9
  9. package/dist/review-checkpoints.js +4 -4
  10. package/dist/server.js +154 -280
  11. package/dist/ui/.vite/manifest.json +280 -280
  12. package/dist/ui/assets/{angular-html-CQmCUfkv.js → angular-html-BT2Z6jee.js} +1 -1
  13. package/dist/ui/assets/{angular-ts-BSbfxpMS.js → angular-ts-BwPE8YDt.js} +1 -1
  14. package/dist/ui/assets/{apl-CWCaH-E3.js → apl-CGbONVvU.js} +1 -1
  15. package/dist/ui/assets/{astro-R_p4G0xA.js → astro-DIAPy7cj.js} +1 -1
  16. package/dist/ui/assets/{blade-irHensDY.js → blade-Cgtd19Ky.js} +1 -1
  17. package/dist/ui/assets/{c-_XEQUoq6.js → c-acnvZ8tm.js} +1 -1
  18. package/dist/ui/assets/{cobol-Css6VQjJ.js → cobol-G-7HLVKW.js} +1 -1
  19. package/dist/ui/assets/{coffee-CpXUkyDm.js → coffee-DQFyKxEB.js} +1 -1
  20. package/dist/ui/assets/{cpp-BQHsPWwI.js → cpp-UyrmHXan.js} +1 -1
  21. package/dist/ui/assets/{crystal-Cbv8J9Fm.js → crystal-DuiWhEO3.js} +1 -1
  22. package/dist/ui/assets/{css-D8DkrS3U.js → css-D_WCbY6m.js} +1 -1
  23. package/dist/ui/assets/{edge-bBL_XDAG.js → edge-COkalhFi.js} +1 -1
  24. package/dist/ui/assets/{elixir-CQjsM9sA.js → elixir-cdlzbYR1.js} +1 -1
  25. package/dist/ui/assets/{elm-DLFl-jEZ.js → elm-ZwDYuYpb.js} +1 -1
  26. package/dist/ui/assets/{erb-BJjsdB_Q.js → erb-C8WG9TtC.js} +1 -1
  27. package/dist/ui/assets/{git-rebase-CqP5A2s0.js → git-rebase-CMlDVJBT.js} +1 -1
  28. package/dist/ui/assets/{glimmer-js-B5Hh_7xe.js → glimmer-js-DeLOQlol.js} +1 -1
  29. package/dist/ui/assets/{glimmer-ts-oaeSAC8s.js → glimmer-ts-DTEM04TK.js} +1 -1
  30. package/dist/ui/assets/{glsl-BylkOpcF.js → glsl-IJV4SB-M.js} +1 -1
  31. package/dist/ui/assets/{graphql-DsVRvP6S.js → graphql-PM-WDnoB.js} +1 -1
  32. package/dist/ui/assets/{hack-D4N5abT_.js → hack-C14sh6Lh.js} +1 -1
  33. package/dist/ui/assets/{haml-CIAJQIox.js → haml-B_5nS6Kx.js} +1 -1
  34. package/dist/ui/assets/{handlebars-DDEZyNXR.js → handlebars-eS-J0O-M.js} +1 -1
  35. package/dist/ui/assets/{heavy-payload-DafM8yjB.js → heavy-payload-BKB0QDOI.js} +1 -1
  36. package/dist/ui/assets/{html-zDy-3lvy.js → html-C3TPjrC5.js} +1 -1
  37. package/dist/ui/assets/{html-derivative-CZ91Y2d5.js → html-derivative-BlVZU2id.js} +1 -1
  38. package/dist/ui/assets/{http-ezaodZRf.js → http-BmbZzA5x.js} +1 -1
  39. package/dist/ui/assets/{hurl-BmL0o7TO.js → hurl-Dnd_YpOD.js} +1 -1
  40. package/dist/ui/assets/{java-Q2C0vwbH.js → java-ne9HdIVn.js} +1 -1
  41. package/dist/ui/assets/{javascript-UDhBkHE3.js → javascript-DpHT58rr.js} +1 -1
  42. package/dist/ui/assets/{jinja-BWRskcYS.js → jinja-DTvkZdHA.js} +1 -1
  43. package/dist/ui/assets/{jison-B18BY9Ds.js → jison-DUJG-tmh.js} +1 -1
  44. package/dist/ui/assets/{json-DNj7MpRr.js → json-DJmnBdxW.js} +1 -1
  45. package/dist/ui/assets/{jsx-CVpHJ6cr.js → jsx-BclJnZnN.js} +1 -1
  46. package/dist/ui/assets/{julia-CJBNBS9c.js → julia-P5B4pHFa.js} +1 -1
  47. package/dist/ui/assets/{just-Bmu2JCQT.js → just-DYaoh7C6.js} +1 -1
  48. package/dist/ui/assets/{latex-BB7F93KY.js → latex-CI5lnkWV.js} +1 -1
  49. package/dist/ui/assets/{liquid-Btnomc6E.js → liquid-D3LnAs_z.js} +1 -1
  50. package/dist/ui/assets/{lua-D0FYT9Vo.js → lua-dZC7ZQIY.js} +1 -1
  51. package/dist/ui/assets/{marko-MHLw0ZlK.js → marko-CRDrjqfS.js} +1 -1
  52. package/dist/ui/assets/{mdc-CSr-suGy.js → mdc-ZzwgmJVn.js} +1 -1
  53. package/dist/ui/assets/{nginx-ur1l6E-g.js → nginx-BAvFXGSV.js} +1 -1
  54. package/dist/ui/assets/{nim-B2k0IugA.js → nim-BHIFawe9.js} +1 -1
  55. package/dist/ui/assets/{perl-BqOHxFDn.js → perl-C_f5AnLa.js} +1 -1
  56. package/dist/ui/assets/{php-DzPK6O1_.js → php-DDqpegL5.js} +1 -1
  57. package/dist/ui/assets/{pug-D169OZkr.js → pug-CSaAZXyd.js} +1 -1
  58. package/dist/ui/assets/{qml-BtemJTC4.js → qml-CNHvRs_z.js} +1 -1
  59. package/dist/ui/assets/{r-BGVHu0bd.js → r-CAfoQND2.js} +1 -1
  60. package/dist/ui/assets/{razor-Diisjg1d.js → razor-BDfAPy7I.js} +1 -1
  61. package/dist/ui/assets/{regexp-B2F5vMZ6.js → regexp-Mug5jltQ.js} +1 -1
  62. package/dist/ui/assets/{review-payload-B-3I3Oj_.js → review-payload-D7GIdYDj.js} +1 -1
  63. package/dist/ui/assets/{rst-BxpeZwuT.js → rst-BzPP2gle.js} +1 -1
  64. package/dist/ui/assets/{ruby-DrCq4riN.js → ruby-CQjzokzX.js} +1 -1
  65. package/dist/ui/assets/{sas-CI_2W-1n.js → sas-Bp37KGr7.js} +1 -1
  66. package/dist/ui/assets/{scrollbar-DHsDF5w-.js → scrollbar-HUMBdLLK.js} +3 -3
  67. package/dist/ui/assets/{scss-BxtyHZ1p.js → scss-C_Z1DYnw.js} +1 -1
  68. package/dist/ui/assets/{shellscript-BY_YYrHT.js → shellscript-CLfJpn3I.js} +1 -1
  69. package/dist/ui/assets/{shellsession-C6uBrLmA.js → shellsession-CNc62UhY.js} +1 -1
  70. package/dist/ui/assets/{soy-BHM9gdPh.js → soy-CF44SpBo.js} +1 -1
  71. package/dist/ui/assets/{sql-XmhfEGz_.js → sql-CYnzfiQ1.js} +1 -1
  72. package/dist/ui/assets/{stata-BbHY7NEo.js → stata-DVeMA68W.js} +1 -1
  73. package/dist/ui/assets/{surrealql-CD49DZ3E.js → surrealql-52sF96Yv.js} +1 -1
  74. package/dist/ui/assets/{svelte-CbYsEYJY.js → svelte-ydwT7UbF.js} +1 -1
  75. package/dist/ui/assets/{templ-B5JoUxEB.js → templ-BXcoVoGh.js} +1 -1
  76. package/dist/ui/assets/{tex-DZcOfTZK.js → tex-COsYFp7O.js} +1 -1
  77. package/dist/ui/assets/{ts-tags-BeU-xqc0.js → ts-tags-CF--LE1s.js} +1 -1
  78. package/dist/ui/assets/{tsx-V7W1xGl3.js → tsx-DLBt2-2f.js} +1 -1
  79. package/dist/ui/assets/{twig-MIaJo5BU.js → twig-YoM-DiI5.js} +1 -1
  80. package/dist/ui/assets/{typescript-C0WESuVc.js → typescript-Ce9JGkNI.js} +1 -1
  81. package/dist/ui/assets/{vue-eJHwc9ra.js → vue-B5LDEy4l.js} +1 -1
  82. package/dist/ui/assets/{vue-html-CLmBujTU.js → vue-html-CPdGGk4T.js} +1 -1
  83. package/dist/ui/assets/{vue-vine-BP5kjIGb.js → vue-vine-B5bjgk0a.js} +1 -1
  84. package/dist/ui/assets/{workspace-app-C1qR5cAY.js → workspace-app-Cm9EX5x4.js} +3 -3
  85. package/dist/ui/assets/{xml-j7pmyP48.js → xml-Cun8xb0T.js} +1 -1
  86. package/dist/ui/assets/{xsl-5VFHbg6o.js → xsl-wls_CEK_.js} +1 -1
  87. package/dist/ui/assets/{yaml-DxFAt-KU.js → yaml-BxuXn9OL.js} +1 -1
  88. package/dist/ui/workspace-app.html +1 -1
  89. package/dist/workspace-store.js +52 -1
  90. package/dist/workspaces.js +209 -29
  91. package/docs/artifact-exchange.md +4 -3
  92. package/docs/chatgpt-coding-workflow.md +70 -11
  93. package/docs/configuration.md +41 -16
  94. package/docs/debugging.md +2 -2
  95. package/docs/gotchas.md +3 -3
  96. package/docs/roadmap.md +12 -1
  97. package/docs/security.md +3 -2
  98. package/package.json +1 -1
  99. package/scripts/debug/accept.mjs +113 -15
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -27,8 +27,9 @@ export class WorkspaceRegistry {
27
27
  async openWorkspace(input, openOptions = {}) {
28
28
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
29
29
  const workspaceInput = typeof input === "string" ? { path: input } : input;
30
+ const bootstrapContext = workspaceInput.context ?? "auto";
30
31
  if (workspaceInput.workspaceId) {
31
- return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId);
32
+ return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId, bootstrapContext);
32
33
  }
33
34
  if (!workspaceInput.path) {
34
35
  throw new Error("open_workspace requires either path or workspaceId.");
@@ -37,24 +38,138 @@ export class WorkspaceRegistry {
37
38
  if (mode === "worktree") {
38
39
  return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
39
40
  }
40
- return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false);
41
+ return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false, bootstrapContext);
41
42
  }
42
- async resumeWorkspace(workspaceId, conversationScopeId) {
43
+ async listWorkspaces(input = {}, openOptions = {}) {
44
+ this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
45
+ const now = Date.now();
46
+ const sessions = this.store
47
+ ? this.store.listSessions()
48
+ : [...this.workspaces.values()].map((workspace) => ({
49
+ id: workspace.id,
50
+ root: workspace.root,
51
+ status: "active",
52
+ mode: workspace.mode,
53
+ sourceRoot: workspace.sourceRoot,
54
+ baseRef: workspace.worktree?.baseRef,
55
+ baseSha: workspace.worktree?.baseSha,
56
+ branch: workspace.worktree?.branch,
57
+ targetBranch: workspace.worktree?.targetBranch,
58
+ managed: workspace.worktree?.managed ?? false,
59
+ createdAt: "",
60
+ lastUsedAt: "",
61
+ }));
62
+ const currentWorkspaceIds = new Set(openOptions.conversationScopeId && this.store
63
+ ? this.store
64
+ .listConversationBindings()
65
+ .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
66
+ .map((binding) => binding.workspaceSessionId)
67
+ : []);
68
+ const rootKey = input.root
69
+ ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
70
+ : undefined;
71
+ const entries = await Promise.all(sessions.map(async (session) => {
72
+ const rootValid = await this.validSessionRoot(session) !== undefined;
73
+ const lastUsedAt = Date.parse(session.lastUsedAt);
74
+ const idleMs = Number.isFinite(lastUsedAt) ? Math.max(0, now - lastUsedAt) : 0;
75
+ const state = session.status !== "active"
76
+ ? "closed"
77
+ : !rootValid
78
+ ? "invalid"
79
+ : idleMs >= WORKSPACE_STALE_REMINDER_MS
80
+ ? "stale"
81
+ : "active";
82
+ const projectRoot = session.sourceRoot ?? session.root;
83
+ return {
84
+ label: `${basename(resolve(projectRoot)) || "workspace"}/${session.id}`,
85
+ workspaceId: session.id,
86
+ root: session.root,
87
+ status: session.status,
88
+ state,
89
+ mode: session.mode,
90
+ sourceRoot: session.sourceRoot,
91
+ branch: session.branch,
92
+ targetBranch: session.targetBranch,
93
+ managed: session.managed,
94
+ createdAt: session.createdAt,
95
+ lastUsedAt: session.lastUsedAt,
96
+ idleMs,
97
+ rootValid,
98
+ current: currentWorkspaceIds.has(session.id),
99
+ };
100
+ }));
101
+ const filtered = [];
102
+ for (let index = 0; index < sessions.length; index += 1) {
103
+ const session = sessions[index];
104
+ const entry = entries[index];
105
+ if (!session || !entry)
106
+ continue;
107
+ if (input.workspaceId && entry.workspaceId !== input.workspaceId)
108
+ continue;
109
+ if (input.status && entry.status !== input.status)
110
+ continue;
111
+ if (input.state && entry.state !== input.state)
112
+ continue;
113
+ if (input.mode && entry.mode !== input.mode)
114
+ continue;
115
+ if (input.staleOnly && entry.state !== "stale")
116
+ continue;
117
+ if (rootKey) {
118
+ const sessionRootKey = await canonicalPath(session.root);
119
+ const sourceRootKey = session.sourceRoot ? await canonicalPath(session.sourceRoot) : undefined;
120
+ if (sessionRootKey !== rootKey && sourceRootKey !== rootKey)
121
+ continue;
122
+ }
123
+ filtered.push(entry);
124
+ }
125
+ const summary = filtered.reduce((counts, entry) => {
126
+ counts[entry.state] += 1;
127
+ return counts;
128
+ }, { active: 0, stale: 0, invalid: 0, closed: 0 });
129
+ const offset = Math.max(0, input.offset ?? 0);
130
+ const limit = Math.min(100, Math.max(1, input.limit ?? 50));
131
+ return {
132
+ workspaces: filtered.slice(offset, offset + limit),
133
+ summary: {
134
+ total: entries.length,
135
+ matching: filtered.length,
136
+ ...summary,
137
+ },
138
+ page: {
139
+ offset,
140
+ limit,
141
+ hasMore: offset + limit < filtered.length,
142
+ },
143
+ };
144
+ }
145
+ async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
43
146
  const workspace = this.getWorkspace(workspaceId);
44
147
  const context = await this.reusedWorkspaceContext(workspace);
45
148
  if (!conversationScopeId || !this.store) {
46
- return { ...context, includeBootstrapContext: true };
149
+ return {
150
+ ...context,
151
+ includeBootstrapContext: bootstrapContext !== "none",
152
+ };
47
153
  }
48
154
  const targetKeys = await this.workspaceTargetKeys(workspace);
49
- const alreadyBound = targetKeys.some((targetKey) => this.store?.getConversationBinding(conversationScopeId, targetKey)?.workspaceSessionId === workspace.id);
155
+ const contextAlreadyDelivered = targetKeys.some((targetKey) => this.store?.getContextDelivery(conversationScopeId, targetKey)?.contextFingerprint ===
156
+ context.contextFingerprint);
157
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, contextAlreadyDelivered);
50
158
  for (const targetKey of targetKeys) {
51
159
  this.store.setConversationBinding({
52
160
  conversationScopeId,
53
161
  targetKey,
54
162
  workspaceSessionId: workspace.id,
55
163
  });
164
+ if (includeBootstrapContext) {
165
+ this.store.setContextDelivery({
166
+ conversationScopeId,
167
+ targetKey,
168
+ contextFingerprint: context.contextFingerprint,
169
+ });
170
+ }
56
171
  }
57
- return { ...context, includeBootstrapContext: !alreadyBound };
172
+ return { ...context, includeBootstrapContext };
58
173
  }
59
174
  async listStaleWorkspaces(workspace) {
60
175
  if (!this.store)
@@ -206,12 +321,12 @@ export class WorkspaceRegistry {
206
321
  }
207
322
  return { ...result, hookReports };
208
323
  }
209
- async openReusableCheckout(path, conversationScopeId, newWorkspace) {
324
+ async openReusableCheckout(path, conversationScopeId, newWorkspace, bootstrapContext) {
210
325
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
211
326
  const projectKey = await canonicalPath(allowedPath);
212
327
  const targetKey = JSON.stringify(["checkout", projectKey, null]);
213
328
  if (!newWorkspace) {
214
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey);
329
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
215
330
  if (boundContext)
216
331
  return boundContext;
217
332
  }
@@ -220,7 +335,7 @@ export class WorkspaceRegistry {
220
335
  const freshContext = reusableWorkspace
221
336
  ? await this.cloneWorkspaceContext(reusableWorkspace)
222
337
  : await this.openCheckoutWorkspace(path);
223
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
338
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
224
339
  }
225
340
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
226
341
  const context = await this.openOnce(operationKey, async () => {
@@ -231,18 +346,19 @@ export class WorkspaceRegistry {
231
346
  ? this.cloneWorkspaceContext(reusableWorkspace)
232
347
  : this.reusedWorkspaceContext(reusableWorkspace);
233
348
  });
234
- return this.withConversationContext(context, conversationScopeId, targetKey);
349
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
235
350
  }
236
351
  async openReusableWorktree(input, conversationScopeId) {
237
352
  const path = input.path;
238
353
  if (!path)
239
354
  throw new Error("Worktree mode requires path.");
355
+ const bootstrapContext = input.context ?? "auto";
240
356
  const managedPath = this.tryManagedWorktreePath(path);
241
357
  if (managedPath) {
242
358
  const worktreeKey = await canonicalPath(managedPath);
243
359
  const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
244
360
  if (!input.newWorkspace) {
245
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey);
361
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
246
362
  if (boundContext)
247
363
  return boundContext;
248
364
  }
@@ -251,7 +367,7 @@ export class WorkspaceRegistry {
251
367
  if (!reusableWorkspace) {
252
368
  throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
253
369
  }
254
- return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey);
370
+ return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
255
371
  }
256
372
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
257
373
  const context = await this.openOnce(operationKey, async () => {
@@ -263,7 +379,7 @@ export class WorkspaceRegistry {
263
379
  ? this.cloneWorkspaceContext(reusableWorkspace)
264
380
  : this.reusedWorkspaceContext(reusableWorkspace);
265
381
  });
266
- return this.withConversationContext(context, conversationScopeId, targetKey);
382
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
267
383
  }
268
384
  const resolvedBase = await resolveManagedWorktreeBase({
269
385
  sourcePath: path,
@@ -274,13 +390,13 @@ export class WorkspaceRegistry {
274
390
  const targetKey = JSON.stringify(["worktree", sourceKey, resolvedBase.targetBranch]);
275
391
  if (input.newWorktree) {
276
392
  const context = await this.openWorktreeWorkspace(path, input.baseRef);
277
- return this.withConversationContext(context, conversationScopeId, targetKey);
393
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
278
394
  }
279
395
  if (!input.newWorkspace) {
280
396
  const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
281
397
  session.sourceRoot !== undefined &&
282
398
  await canonicalPath(session.sourceRoot) === sourceKey &&
283
- session.targetBranch === resolvedBase.targetBranch);
399
+ session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
284
400
  if (boundContext)
285
401
  return boundContext;
286
402
  }
@@ -289,7 +405,7 @@ export class WorkspaceRegistry {
289
405
  const freshContext = reusableWorkspace
290
406
  ? await this.cloneWorkspaceContext(reusableWorkspace)
291
407
  : await this.openWorktreeWorkspace(path, input.baseRef);
292
- return this.withConversationContext(freshContext, conversationScopeId, targetKey);
408
+ return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
293
409
  }
294
410
  const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
295
411
  const context = await this.openOnce(operationKey, async () => {
@@ -300,7 +416,7 @@ export class WorkspaceRegistry {
300
416
  ? this.cloneWorkspaceContext(reusableWorkspace)
301
417
  : this.reusedWorkspaceContext(reusableWorkspace);
302
418
  });
303
- return this.withConversationContext(context, conversationScopeId, targetKey);
419
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
304
420
  }
305
421
  async openOnce(operationKey, open) {
306
422
  const pending = this.pendingOpens.get(operationKey);
@@ -338,7 +454,7 @@ export class WorkspaceRegistry {
338
454
  ? JSON.stringify(["conversation", conversationScopeId, targetKey])
339
455
  : targetKey;
340
456
  }
341
- async boundConversationContext(conversationScopeId, targetKey, mode, matches) {
457
+ async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
342
458
  if (!conversationScopeId || !this.store)
343
459
  return undefined;
344
460
  const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
@@ -359,24 +475,30 @@ export class WorkspaceRegistry {
359
475
  return undefined;
360
476
  }
361
477
  const context = await this.reusedWorkspaceContext(this.getWorkspace(session.id));
362
- this.store.touchConversationBinding(conversationScopeId, targetKey);
363
- return { ...context, includeBootstrapContext: false };
478
+ return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
364
479
  }
365
- withConversationContext(context, conversationScopeId, targetKey) {
480
+ withConversationContext(context, conversationScopeId, targetKey, bootstrapContext) {
366
481
  if (!conversationScopeId || !this.store) {
367
- return { ...context, includeBootstrapContext: true };
368
- }
369
- const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
370
- if (binding?.workspaceSessionId === context.workspace.id) {
371
- this.store.touchConversationBinding(conversationScopeId, targetKey);
372
- return { ...context, includeBootstrapContext: false };
482
+ return {
483
+ ...context,
484
+ includeBootstrapContext: bootstrapContext !== "none",
485
+ };
373
486
  }
487
+ const delivery = this.store.getContextDelivery(conversationScopeId, targetKey);
488
+ const includeBootstrapContext = resolveBootstrapContextVisibility(bootstrapContext, delivery?.contextFingerprint === context.contextFingerprint);
374
489
  this.store.setConversationBinding({
375
490
  conversationScopeId,
376
491
  targetKey,
377
492
  workspaceSessionId: context.workspace.id,
378
493
  });
379
- return { ...context, includeBootstrapContext: true };
494
+ if (includeBootstrapContext) {
495
+ this.store.setContextDelivery({
496
+ conversationScopeId,
497
+ targetKey,
498
+ contextFingerprint: context.contextFingerprint,
499
+ });
500
+ }
501
+ return { ...context, includeBootstrapContext };
380
502
  }
381
503
  pruneIdleWorkspaceSessions(protectedWorkspaceIds, force = false) {
382
504
  if (!this.store)
@@ -397,6 +519,13 @@ export class WorkspaceRegistry {
397
519
  this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
398
520
  }
399
521
  }
522
+ for (const delivery of this.store.listContextDeliveries()) {
523
+ const deliveredAt = Date.parse(delivery.deliveredAt);
524
+ if (Number.isFinite(deliveredAt) &&
525
+ now - deliveredAt >= WORKSPACE_SESSION_IDLE_TTL_MS) {
526
+ this.store.deleteContextDelivery(delivery.conversationScopeId, delivery.targetKey);
527
+ }
528
+ }
400
529
  const boundWorkspaceIds = new Set(this.store.listConversationBindings().map((binding) => binding.workspaceSessionId));
401
530
  const worktreeAnchors = new Map();
402
531
  for (const session of activeSessions) {
@@ -499,13 +628,17 @@ export class WorkspaceRegistry {
499
628
  });
500
629
  }
501
630
  async reusedWorkspaceContext(workspace) {
631
+ Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
632
+ workspace.capabilityGuides = loadCapabilityGuides(this.config);
502
633
  workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
503
634
  const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
504
635
  const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
636
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
505
637
  return {
506
638
  workspace,
507
639
  agentsFiles,
508
640
  availableAgentsFiles,
641
+ contextFingerprint,
509
642
  hookReports: [],
510
643
  workspaceReused: true,
511
644
  includeBootstrapContext: true,
@@ -676,10 +809,12 @@ export class WorkspaceRegistry {
676
809
  });
677
810
  const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
678
811
  const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
812
+ const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
679
813
  return {
680
814
  workspace,
681
815
  agentsFiles,
682
816
  availableAgentsFiles,
817
+ contextFingerprint,
683
818
  hookReports,
684
819
  workspaceReused: false,
685
820
  includeBootstrapContext: true,
@@ -761,6 +896,51 @@ export class WorkspaceRegistry {
761
896
  return discovered.sort((a, b) => a.path.localeCompare(b.path));
762
897
  }
763
898
  }
899
+ function resolveBootstrapContextVisibility(mode, contextAlreadyDelivered) {
900
+ if (mode === "full")
901
+ return true;
902
+ if (mode === "none")
903
+ return false;
904
+ return !contextAlreadyDelivered;
905
+ }
906
+ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles) {
907
+ const payload = {
908
+ agentsFiles: agentsFiles
909
+ .map((file) => ({ path: resolve(file.path), content: file.content }))
910
+ .sort((left, right) => left.path.localeCompare(right.path)),
911
+ availableAgentsFiles: availableAgentsFiles
912
+ .map((file) => resolve(file.path))
913
+ .sort((left, right) => left.localeCompare(right)),
914
+ skills: workspace.skills
915
+ .map((skill) => ({
916
+ name: skill.name,
917
+ description: skill.description,
918
+ filePath: resolve(skill.filePath),
919
+ disableModelInvocation: skill.disableModelInvocation ?? false,
920
+ }))
921
+ .sort((left, right) => left.name.localeCompare(right.name) || left.filePath.localeCompare(right.filePath)),
922
+ skillDiagnostics: workspace.skillDiagnostics,
923
+ capabilityGuides: workspace.capabilityGuides
924
+ .map((guide) => ({
925
+ name: guide.name,
926
+ description: guide.description,
927
+ whenToRead: guide.whenToRead,
928
+ filePath: resolve(guide.filePath),
929
+ }))
930
+ .sort((left, right) => left.name.localeCompare(right.name)),
931
+ agentProfiles: workspace.agentProfiles
932
+ .map((profile) => ({
933
+ name: profile.name,
934
+ description: profile.description,
935
+ provider: profile.provider,
936
+ model: profile.model,
937
+ thinking: profile.thinking,
938
+ filePath: resolve(profile.filePath),
939
+ }))
940
+ .sort((left, right) => left.name.localeCompare(right.name)),
941
+ };
942
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
943
+ }
764
944
  async function canonicalPath(path) {
765
945
  const missingSegments = [];
766
946
  let candidate = path;
@@ -14,11 +14,12 @@ The legacy `DEVSPACE_ARTIFACTS` variable remains a fallback during migration.
14
14
  ## Workflow
15
15
 
16
16
  1. Open the destination project with `open_workspace`.
17
- 2. Call `download_artifact` with the native file value supplied by the MCP host,
18
- the existing `workspaceId`, and a workspace-relative destination path.
17
+ 2. Call the `capability` Gateway with `name="artifact.download"`, `action="run"`,
18
+ the existing `workspaceId`, the native file value in the Gateway's top-level
19
+ `file` slot, and a workspace-relative destination in `arguments.path`.
19
20
  3. Use the returned normalized path with ordinary ForgeRelay file tools.
20
21
 
21
- The tool creates missing parent directories and refuses to overwrite an existing
22
+ The capability creates missing parent directories and refuses to overwrite an existing
22
23
  destination.
23
24
 
24
25
  ## Input boundary
@@ -18,6 +18,60 @@ existing ID explicitly when the user wants to resume that logical workspace.
18
18
  A Git worktree directory is a separate physical workspace target from its source
19
19
  checkout, and each conversation can still have its own logical handle for it.
20
20
 
21
+ ### Bootstrap context and workspace inventory
22
+
23
+ Normal coding still starts with the shortest path:
24
+
25
+ ```text
26
+ open_workspace(path="~/project")
27
+ ```
28
+
29
+ The default `context="auto"` keeps the first useful bootstrap while avoiding
30
+ replay. ForgeRelay tracks delivered context by conversation plus canonical
31
+ workspace target and a content fingerprint, not by logical `workspaceId`. If the
32
+ same conversation opens or resumes another logical handle for the same physical
33
+ project and the fingerprint is unchanged, the response keeps only lightweight
34
+ workspace metadata. If loaded instruction contents or relevant Skill, Capability
35
+ guide, profile, diagnostic, or nested-instruction metadata changes, the next
36
+ automatic open returns the refreshed bootstrap.
37
+
38
+ Two explicit controls are available for exceptional cases:
39
+
40
+ ```text
41
+ open_workspace(workspaceId="ws_...", context="full")
42
+ open_workspace(workspaceId="ws_...", context="none")
43
+ ```
44
+
45
+ `full` forces a bootstrap refresh. `none` opens/resumes the workspace without
46
+ returning the full project context and does not record the current fingerprint as
47
+ already delivered. Context-delivery state is independent from logical-workspace
48
+ selection, so closing or switching one handle does not make the conversation
49
+ forget unchanged project context it already received.
50
+
51
+ Do not enumerate workspace state on every normal open. When the user wants to
52
+ continue an earlier task, choose among logical workspaces, or clean up accumulated
53
+ state, use the same Core tool in inventory mode:
54
+
55
+ ```text
56
+ open_workspace(action="list")
57
+ open_workspace(action="list", root="~/project")
58
+ open_workspace(action="list", staleOnly=true)
59
+ ```
60
+
61
+ Inventory is paginated, defaults to 50 entries, and caps each page at 100. It can
62
+ filter by `workspaceId`, persisted `status`, derived `state`, `mode`, canonical
63
+ root/source root, or stale-only state. Entries include a compact label such as
64
+ `project/ws_...`, checkout/worktree backing metadata, timestamps, idle duration,
65
+ root validity, and whether that logical workspace is currently selected by this
66
+ conversation. Listing is observational and does not refresh `lastUsedAt`.
67
+
68
+ Treat persisted status and derived state separately. `status="active"` means the
69
+ record has not been explicitly closed. A valid recent record has `state="active"`;
70
+ a valid active record idle for more than two days has `state="stale"`; an active
71
+ record whose root is missing or unusable has `state="invalid"`; and an explicitly
72
+ finalized persisted record has `state="closed"`. Ask the user before cleanup, then
73
+ use the existing `close_workspace` lifecycle on the selected `workspaceId`.
74
+
21
75
  ## Checkout-first behavior
22
76
 
23
77
  Checkout mode is the default:
@@ -131,8 +185,9 @@ worktrees, subagents, artifact/change-review workflows, Host/OAuth/MCP App
131
185
  integration, and long-running shell/PTY/process behavior. Optional guides are
132
186
  advertised only when their feature is enabled; for example, disabled subagents
133
187
  and artifact/change-review features do not add those descriptors to bootstrap
134
- context. Reopening a workspace in the same Host context does not repeat the
135
- descriptors, but the previously advertised guides remain valid.
188
+ context. Bootstrap replay follows the conversation/canonical-target context
189
+ fingerprint described above, so changing logical `workspaceId` alone does not
190
+ repeat unchanged descriptors; previously advertised guides remain valid.
136
191
 
137
192
  The fingerprint is also a stale-Host-schema diagnostic. If `open_workspace`
138
193
  reports a capability such as `filesystem.rename-move` but the Host's current
@@ -209,7 +264,9 @@ killing it. Reuse `bash(action="process", processId=...)` to poll/wait, send inp
209
264
  resize a PTY, or interrupt the existing process; or continue other work and consume
210
265
  the one-shot completion notice from a later result in the same workspace.
211
266
 
212
- `FORGERELAY_TOOL_MODE=full` adds dedicated search/directory tools.
267
+ `FORGERELAY_TOOL_MODE=full` is retained as a compatibility value and exposes the
268
+ same canonical 9-tool surface as `minimal`; use `bash` for search and directory
269
+ inspection.
213
270
 
214
271
  Experimental `FORGERELAY_TOOL_MODE=codex` keeps its Codex-shaped compatibility
215
272
  surface, including direct `rename`/`delete` path mutations alongside `apply_patch`,
@@ -221,12 +278,14 @@ Workspace IDs are logical conversation handles rather than physical-directory
221
278
  identities. The same conversation keeps a stable ID for a project, while another
222
279
  conversation normally receives a different ID pointing at the same checkout or
223
280
  worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
224
- fresh logical ID is created only when the user asks for one. When a project has
225
- other logical workspaces idle for more than two days, `open_workspace` reports
226
- all of them so the user can choose to resume or clean them up. `close_workspace`
227
- is the single public close operation: checkout-backed workspaces release the logical
228
- handle, while managed-worktree-backed workspaces require `commitMessage` and run the
229
- safe commit / fast-forward-only integration / cleanup lifecycle.
281
+ fresh logical ID is created only when the user asks for one. The normal open path
282
+ may still include `staleWorkspaces` as a passive reminder for same-target handles
283
+ idle for more than two days; use `open_workspace(action="list")` for complete,
284
+ filtered inventory when continuation or cleanup actually requires it.
285
+ `close_workspace` is the single public close operation: checkout-backed workspaces
286
+ release the logical handle, while managed-worktree-backed workspaces require
287
+ `commitMessage` and run the safe commit / fast-forward-only integration / cleanup
288
+ lifecycle.
230
289
 
231
290
  Shell commands are allowed to modify ordinary project files when that is a
232
291
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -247,8 +306,8 @@ an explicit user request before changing configuration files through `bash` or
247
306
  By default `FORGERELAY_WIDGETS=full` attaches ChatGPT Apps-compatible UI to the
248
307
  normal workspace/file/edit/shell tools.
249
308
 
250
- `FORGERELAY_WIDGETS=changes` exposes aggregate `show_changes` behavior and keeps
251
- widget usage focused on workspace/change review.
309
+ `FORGERELAY_WIDGETS=changes` enables aggregate `review.changes` Capability results
310
+ and keeps widget usage focused on workspace/change review.
252
311
 
253
312
  `FORGERELAY_WIDGETS=off` disables widget UI.
254
313
 
@@ -78,7 +78,7 @@ FORGERELAY_ARTIFACTS=1 npx @akira-tl/forgerelay serve
78
78
 
79
79
  | Variable | Default | Purpose |
80
80
  | --- | --- | --- |
81
- | `FORGERELAY_ARTIFACTS` | `0` | Expose `download_artifact` for trusted native files. |
81
+ | `FORGERELAY_ARTIFACTS` | `0` | Advertise the `artifact.download` Capability for trusted native files. |
82
82
  | `FORGERELAY_ARTIFACT_MAX_FILE_BYTES` | `104857600` | Maximum streamed size of one file (100 MiB). |
83
83
 
84
84
  The same settings may be persisted as `artifactsEnabled` and
@@ -115,20 +115,22 @@ MCP clients discover metadata from:
115
115
 
116
116
  | Value | Behavior |
117
117
  | --- | --- |
118
- | `minimal` | Default. Exposes `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `rename`, `delete`, `bash`, and `capability`. |
119
- | `full` | Adds dedicated `grep`, `glob`, and `ls` tools. |
120
- | `codex` | Experimental Codex-shaped compatibility surface using `open_workspace`, `close_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, `write_stdin`, and `capability`. |
118
+ | `minimal` | Default canonical surface: `open_workspace`, `capability`, `close_workspace`, `read`, `write`, `edit`, `rename`, `delete`, and `bash`. |
119
+ | `full` | Compatibility value. Uses the same canonical 9-tool surface as `minimal`; search and directory inspection go through `bash`. |
120
+ | `codex` | Experimental Codex-shaped compatibility adapter using `open_workspace`, `close_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, `write_stdin`, and `capability`. It does not define the ForgeRelay canonical interface. |
121
121
 
122
122
  `FORGERELAY_MINIMAL_TOOLS` remains a compatibility-style boolean alias when the
123
123
  explicit tool mode is unset. The corresponding legacy `DEVSPACE_*` names are
124
124
  also accepted.
125
125
 
126
- The selected mode controls the real `tools/list` surface. ForgeRelay does not
127
- hide callable tools behind capability documentation. In every mode,
126
+ `minimal` and `full` now resolve to the same regular 9-tool `tools/list`; `full`
127
+ is retained only as a configuration-compatibility value. `codex` selects a
128
+ separate compatibility adapter. ForgeRelay does not hide core callable tools
129
+ behind capability documentation. In every mode,
128
130
  `open_workspace` returns a `capabilityFingerprint` containing the package
129
131
  version, tool mode, and stable semantic capability names. The fingerprint also
130
132
  reports enabled optional domains such as subagent profile discovery, native
131
- artifact download, MCP App UI, or aggregate `show_changes` review when those
133
+ artifact download, MCP App UI, or aggregate `review.changes` when those
132
134
  features are actually available; it remains a semantic summary rather than a
133
135
  copy of `tools/list`.
134
136
 
@@ -142,8 +144,8 @@ the corresponding feature is enabled, so disabled features do not add bootstrap
142
144
  context.
143
145
 
144
146
  There is no separate progressive-disclosure configuration switch. Capability
145
- Guide discovery is built in, while actual tool exposure continues to be
146
- controlled by `FORGERELAY_TOOL_MODE` and feature-specific settings. If the
147
+ Guide discovery is built in, while optional low-frequency actions are advertised
148
+ through the workspace Capability catalog instead of adding top-level tools. If the
147
149
  fingerprint reports a capability that is missing from the Host's current tool
148
150
  snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integration
149
151
  or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
@@ -161,12 +163,35 @@ receives a different ID for the same physical checkout/worktree. Pass
161
163
  `workspaceId` to `open_workspace` to explicitly resume an existing handle in the
162
164
  current conversation. `newWorkspace: true` allocates a new logical handle without
163
165
  creating another checkout or Git worktree and should be used only on explicit user
164
- request. Logical workspaces idle for more than two days are returned in `staleWorkspaces` so
165
- the user can choose whether to resume or release them. `close_workspace` removes a
166
- checkout-backed logical handle without deleting checkout files. For a managed-worktree-backed
167
- workspace, `close_workspace` requires `commitMessage` and runs the existing safe
168
- worktree finalize lifecycle: close Hooks, commit when needed, fast-forward-only
169
- integration, cleanup, and alias invalidation.
166
+ request.
167
+
168
+ Bootstrap delivery is tracked separately from the selected logical workspace.
169
+ `open_workspace` defaults to `context="auto"`: ForgeRelay fingerprints the current
170
+ project context and returns the full AGENTS/Skills/Capability-guide/profile bootstrap
171
+ only when that conversation has not already received the current fingerprint for
172
+ the canonical workspace target. `context="full"` forces a refresh;
173
+ `context="none"` opens or resumes the logical workspace without returning the full
174
+ bootstrap and does not mark the current fingerprint as delivered. Closing or
175
+ switching a logical workspace therefore does not by itself cause unchanged project
176
+ context to be injected again, while changed context produces a new fingerprint and
177
+ is delivered on the next `auto` open.
178
+
179
+ Use `open_workspace(action="list")` only when the Agent needs to continue an older
180
+ logical workspace, choose among multiple handles, or organize workspace state. The
181
+ inventory is paginated (50 records by default, at most 100) and can filter by
182
+ workspace ID, persisted status, derived state, mode, canonical root/source root, or
183
+ stale-only state. Reading inventory does not refresh `lastUsedAt`. Persisted
184
+ `status="active"` means the record has not been explicitly closed; the derived
185
+ `state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
186
+ or externally removed managed-worktree root can therefore remain diagnostically
187
+ `status="active"` while appearing as `state="invalid"`. The existing
188
+ `staleWorkspaces` field remains a passive same-workspace reminder for old handles;
189
+ `action="list"` is the formal on-demand inventory path.
190
+
191
+ `close_workspace` removes a checkout-backed logical handle without deleting checkout
192
+ files. For a managed-worktree-backed workspace, `close_workspace` requires
193
+ `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
194
+ commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
170
195
 
171
196
  Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
172
197
  in the foreground for at most 300 seconds; if the process is still alive, the
@@ -186,7 +211,7 @@ regular Agent workflows should use the single `bash` process lifecycle.
186
211
  | Value | Behavior |
187
212
  | --- | --- |
188
213
  | `full` | Default. Attach UI to exposed workspace/file/edit/shell tools. |
189
- | `changes` | Attach UI to `open_workspace` and aggregate `show_changes`. |
214
+ | `changes` | Attach UI to `open_workspace` and Capability Gateway review results from `review.changes`. |
190
215
  | `off` | Disable widget UI. |
191
216
 
192
217
  ## Lifecycle hooks
package/docs/debugging.md CHANGED
@@ -59,9 +59,9 @@ The acceptance checks:
59
59
  3. unauthenticated `/mcp` rejection;
60
60
  4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
61
61
  5. MCP `initialize`, including package/server version consistency and the shell mutation safety contract;
62
- 6. `tools/list` for the full debug tool surface, including unified `close_workspace`, absence of regular `write_stdin` / `close_worktree`, canonical `bash` `action="run"` / `action="process"` plus `processId`, the non-blanket shell mutation policy, no kill-timeout input, workspace resume/stale-workspace schema, and MCP App tool metadata;
62
+ 6. `tools/list` for the exact canonical 9-tool regular surface, including unified `close_workspace`, the single `capability` Gateway, canonical `bash` `action="run"` / `action="process"` plus `processId`, absence of retired dedicated aliases/search tools, the non-blanket shell mutation policy, workspace resume/stale-workspace schema, and MCP App tool metadata;
63
63
  7. the full MCP App template chain: `resources/list`, `resources/templates/list`, current content-hashed `resources/read`, legacy/historical template compatibility reads, `text/html;profile=mcp-app`, the unique app domain plus CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
64
- 8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash`, `bash` long-process `run` → `processId` → `process`, and a deliberate failed `edit`;
64
+ 8. a real Git checkout workspace with Capability catalog/describe/read/run, `review.changes`, `write`, `read`, `rename`, `delete`, foreground `bash`, `bash` long-process `run` → `processId` → `process`, and a deliberate failed `edit`; on Linux the catalog/describe path also verifies `artifact.download` native-file transport metadata;
65
65
  9. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP transport session, plus rejection of an arbitrary path outside the workspace/temp roots;
66
66
  10. a temporary Git repository with managed worktree creation, file modification, and `close_workspace` worktree finalization;
67
67
  11. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;