@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
package/docs/gotchas.md CHANGED
@@ -234,8 +234,8 @@ Per-tool widgets default to:
234
234
  FORGERELAY_WIDGETS=full
235
235
  ```
236
236
 
237
- Use `FORGERELAY_WIDGETS=changes` for aggregate `show_changes`, or `off` to
238
- disable UI. Plain MCP clients may ignore MCP App widget metadata.
237
+ Use `FORGERELAY_WIDGETS=changes` for aggregate `review.changes` Capability results,
238
+ or `off` to disable UI. Plain MCP clients may ignore MCP App widget metadata.
239
239
 
240
240
  If ChatGPT shows `Failed to fetch template`, first verify the server-side template
241
241
  chain with:
@@ -245,7 +245,7 @@ npm run build
245
245
  npm run debug:accept
246
246
  ```
247
247
 
248
- The acceptance runner enables full widgets and checks that the tool advertises a
248
+ The acceptance runner enables review-focused widgets and checks that the Gateway advertises a
249
249
  content-hashed `ui://forgerelay/workspace-app-<hash>.html` resource, that
250
250
  `resources/read` returns `text/html;profile=mcp-app`, and that the referenced
251
251
  JavaScript asset is reachable. ForgeRelay also keeps the legacy
package/docs/roadmap.md CHANGED
@@ -170,7 +170,18 @@ capability
170
170
  - 删除已经完成迁移的 dedicated low-frequency tool aliases,确保新增 Capability 不再扩大常驻 tool count;
171
171
  - 简化 fingerprint,使其用于版本/运行时能力摘要与 stale-Host 诊断,而不是重新枚举 tool implementation;
172
172
  - 对 `open_workspace → catalog → capability describe/read/run`、managed worktree close、长进程 interaction、review/artifact capability、MCP App 与 stale-schema 情况做 7677 acceptance 和新 Host 会话验收;
173
- - 0.3.5 通过后,0.3 的 MCP progressive-disclosure 主题视为完成,0.4 回到原定 LSP code intelligence v1。
173
+ - 0.3.5 完成 canonical MCP surface 与 fresh-Host 主验收后,0.3 的 progressive-disclosure 主体设计视为稳定;后续 0.3.x 只接收验收暴露出的兼容性或 lifecycle 补丁,0.4 仍回到原定 LSP code intelligence v1。
174
+
175
+ ### 0.3.6 — Workspace bootstrap 与 inventory 补丁
176
+
177
+ 0.3.6 处理 0.3.5 fresh-Host 验收后暴露的 Workspace 上下文与 logical-workspace 管理问题,不增加新的常驻 MCP tool,也不改变 0.4 的 LSP 主路线:
178
+
179
+ - `open_workspace` 的 bootstrap 去重从 logical `workspaceId` 身份提升为 conversation scope + canonical workspace target + context fingerprint;同一 conversation 切换到同一物理 checkout/worktree 的其他 logical workspace 时,只要 AGENTS、Skills、Capability guide/profile 等相关上下文没有变化,就不重复注入完整 bootstrap;
180
+ - `open_workspace` 提供 `context="auto" | "full" | "none"`。`auto` 默认只在当前 fingerprint 尚未交付或已变化时返回完整上下文,`full` 强制刷新,`none` 只取得 workspace handle/metadata 且不会把该 fingerprint 标记为已交付;
181
+ - context-delivery 状态与 conversation 当前绑定的 logical workspace 分离持久化,因此关闭或切换 logical handle 不会让 Agent 在同一 conversation 中忘记已经收到的项目上下文;delivery 状态继续服从有限生命周期清理,而不是永久缓存;
182
+ - `open_workspace(action="list")` 成为按需 logical-workspace inventory 入口,不增加 `list_workspaces`/`workspace.list` 第十个 Core tool;普通开发仍直接使用默认 `action="open"`,只有续接旧任务、选择 workspace 或整理状态时才读取 inventory;
183
+ - inventory 区分持久化 `status` 与派生 `state`:`status="active"` 表示尚未显式关闭,`state` 再区分 active、stale、invalid 与 closed;missing root 或外部删除的 managed worktree 可以保持可诊断的 active record,同时显示为 invalid;
184
+ - inventory 查看本身不刷新 workspace `lastUsedAt`,支持过滤与分页,并继续让现有 `close_workspace` 承担用户确认后的实际清理/finalize lifecycle。
174
185
 
175
186
  必要安全语义始终留在 Core tool interface、Capability contract 或自动 Hook report 中;渐进式披露不能成为隐藏权限、隐式 autonomous workflow 或绕过 allowed roots/auth 的机制。`rename` 继续作为文件和目录 move/rename 的统一 primitive。
176
187
 
package/docs/security.md CHANGED
@@ -179,8 +179,9 @@ the source checkout into a merge-conflict state.
179
179
 
180
180
  ## Native artifact download
181
181
 
182
- When enabled, `download_artifact` accepts only the supported native file value
183
- provided by the MCP host. It does not accept arbitrary signed URLs, local paths,
182
+ When enabled, the registered `artifact.download` Capability accepts only the supported
183
+ native file value provided by the MCP host through the `capability` Gateway. It does
184
+ not accept arbitrary signed URLs, local paths,
184
185
  embedded credentials, or base64 strings as substitutes.
185
186
 
186
187
  Downloads are streamed, size-limited, created without overwrite, and published
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",
@@ -37,14 +37,16 @@ assertCurlAvailable();
37
37
  await assertDebugPortFree();
38
38
  rmSync(acceptanceRoot, { recursive: true, force: true });
39
39
  mkdirSync(acceptanceRoot, { recursive: true });
40
+ setupGitProject(checkoutWorkspace);
40
41
 
41
42
  const { env } = createDebugEnvironment({
42
43
  ownerToken,
43
44
  stateDir,
44
45
  worktreeRoot,
45
46
  hookLog,
46
- widgets: "full",
47
+ widgets: "changes",
47
48
  });
49
+ env.FORGERELAY_ARTIFACTS = "1";
48
50
  const doctor = spawnSync(process.execPath, ["--import", "tsx", "src/cli.ts", "doctor"], {
49
51
  cwd: repoRoot,
50
52
  env,
@@ -53,7 +55,7 @@ const doctor = spawnSync(process.execPath, ["--import", "tsx", "src/cli.ts", "do
53
55
  assert.equal(doctor.status, 0, doctor.stderr);
54
56
  assert.match(doctor.stdout, /Public base URL: http:\/\/127\.0\.0\.1:7677/);
55
57
  assert.match(doctor.stdout, /Tool mode: full/);
56
- assert.match(doctor.stdout, /Widgets: full/);
58
+ assert.match(doctor.stdout, /Widgets: changes/);
57
59
  assert.match(doctor.stdout, /Trust proxy: off/);
58
60
  pass("doctor resolved MCP shape", "public URL + tool/widgets/proxy state");
59
61
 
@@ -129,11 +131,17 @@ try {
129
131
  params: {},
130
132
  }).message.result.tools;
131
133
  const toolNames = tools.map((tool) => tool.name);
132
- for (const expected of ["open_workspace", "close_workspace", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "capability"]) {
133
- assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
134
- }
135
- assert.equal(toolNames.includes("close_worktree"), false);
136
- assert.equal(toolNames.includes("write_stdin"), false);
134
+ assert.deepEqual(toolNames, [
135
+ "open_workspace",
136
+ "capability",
137
+ "close_workspace",
138
+ "read",
139
+ "write",
140
+ "edit",
141
+ "rename",
142
+ "delete",
143
+ "bash",
144
+ ]);
137
145
  const bashTool = tools.find((tool) => tool.name === "bash");
138
146
  assert.match(bashTool?.description ?? "", /local user's authority/);
139
147
  assert.doesNotMatch(bashTool?.description ?? "", /may modify ordinary project files/);
@@ -152,14 +160,16 @@ try {
152
160
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityFingerprint);
153
161
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityCatalog);
154
162
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
155
- const templateUri = bashTool?._meta?.ui?.resourceUri;
163
+ const capabilityTool = tools.find((tool) => tool.name === "capability");
164
+ assert.deepEqual(capabilityTool?._meta?.["openai/fileParams"], ["file"]);
165
+ const templateUri = capabilityTool?._meta?.ui?.resourceUri;
156
166
  assert.match(
157
167
  templateUri ?? "",
158
168
  /^ui:\/\/forgerelay\/workspace-app-[0-9a-f]{12}\.html$/,
159
169
  JSON.stringify(bashTool ?? {}),
160
170
  );
161
- assert.deepEqual(bashTool?._meta?.ui?.visibility, ["model", "app"]);
162
- assert.equal(bashTool?._meta?.["openai/outputTemplate"], templateUri);
171
+ assert.deepEqual(capabilityTool?._meta?.ui?.visibility, ["model", "app"]);
172
+ assert.equal(capabilityTool?._meta?.["openai/outputTemplate"], templateUri);
163
173
  pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
164
174
 
165
175
  const resources = mcpRequest(oauth.accessToken, sessionId, {
@@ -213,13 +223,16 @@ try {
213
223
  assert.equal(historicalTemplate.text, template.text);
214
224
  pass("MCP app template", `${templateUri} + legacy/history compatibility -> ${scriptUrl}`);
215
225
 
226
+ const workspaceConversationMeta = { "openai/session": "acceptance-workspace" };
216
227
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
217
228
  path: checkoutWorkspace,
218
- });
229
+ }, workspaceConversationMeta);
219
230
  const workspaceId = opened.structuredContent.workspaceId;
220
231
  assert.match(workspaceId, /^ws_/);
232
+ assert.equal(opened.structuredContent.action, "open");
221
233
  assert.equal(opened.structuredContent.root, checkoutWorkspace);
222
234
  assert.equal(opened.structuredContent.mode, "checkout");
235
+ assert.equal(typeof opened.structuredContent.contextFingerprint, "string");
223
236
  assert.deepEqual(opened.structuredContent.capabilityFingerprint, {
224
237
  version: packageJson.version,
225
238
  toolMode: "full",
@@ -231,14 +244,69 @@ try {
231
244
  "process.lifecycle",
232
245
  "hooks.lifecycle",
233
246
  "capability-guides.read",
234
- "inspection.search-tools",
247
+ ...(process.platform === "linux" ? ["artifact.native-download"] : []),
235
248
  "ui.mcp-app",
249
+ "review.changes",
236
250
  ],
237
251
  });
238
252
  const capabilityCatalog = opened.structuredContent.capabilityCatalog;
239
- assert.deepEqual(capabilityCatalog.map((entry) => entry.name), ["hooks.check"]);
253
+ assert.deepEqual(capabilityCatalog.map((entry) => entry.name), [
254
+ "hooks.check",
255
+ "review.changes",
256
+ ...(process.platform === "linux" ? ["artifact.download"] : []),
257
+ ]);
240
258
  assert.equal(capabilityCatalog[0].available, true);
241
259
  assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
260
+
261
+ const freshLogical = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
262
+ path: checkoutWorkspace,
263
+ newWorkspace: true,
264
+ }, workspaceConversationMeta);
265
+ const freshLogicalId = freshLogical.structuredContent.workspaceId;
266
+ assert.notEqual(freshLogicalId, workspaceId);
267
+ assert.equal(freshLogical.structuredContent.action, "open");
268
+ assert.equal(
269
+ freshLogical.structuredContent.contextFingerprint,
270
+ opened.structuredContent.contextFingerprint,
271
+ );
272
+ assert.equal(freshLogical.structuredContent.agentsFiles, undefined);
273
+ assert.equal(freshLogical.structuredContent.capabilityGuides, undefined);
274
+
275
+ const workspaceInventory = callTool(oauth.accessToken, sessionId, 85, "open_workspace", {
276
+ action: "list",
277
+ root: checkoutWorkspace,
278
+ }, workspaceConversationMeta);
279
+ assert.equal(workspaceInventory.structuredContent.action, "list");
280
+ assert.equal(workspaceInventory.structuredContent.summary.matching, 2);
281
+ const inventoryEntries = workspaceInventory.structuredContent.workspaces;
282
+ assert.equal(inventoryEntries.length, 2);
283
+ assert.equal(
284
+ inventoryEntries.find((entry) => entry.workspaceId === freshLogicalId)?.current,
285
+ true,
286
+ );
287
+ assert.equal(
288
+ inventoryEntries.find((entry) => entry.workspaceId === workspaceId)?.current,
289
+ false,
290
+ );
291
+
292
+ const closedFreshLogical = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
293
+ workspaceId: freshLogicalId,
294
+ });
295
+ assert.equal(closedFreshLogical.isError, undefined);
296
+ const resumedOriginal = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
297
+ workspaceId,
298
+ }, workspaceConversationMeta);
299
+ assert.equal(resumedOriginal.structuredContent.workspaceId, workspaceId);
300
+ assert.equal(resumedOriginal.structuredContent.agentsFiles, undefined);
301
+ assert.equal(
302
+ resumedOriginal.structuredContent.contextFingerprint,
303
+ opened.structuredContent.contextFingerprint,
304
+ );
305
+ pass(
306
+ "workspace context + inventory",
307
+ `${workspaceId} -> ${freshLogicalId} -> list -> close -> resume without bootstrap replay`,
308
+ );
309
+
242
310
  const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
243
311
  workspaceId,
244
312
  name: "hooks.check",
@@ -255,6 +323,18 @@ try {
255
323
  assert.equal(describedCapability.isError, undefined);
256
324
  assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
257
325
  assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
326
+ if (process.platform === "linux") {
327
+ const describedArtifact = callTool(oauth.accessToken, sessionId, 82, "capability", {
328
+ workspaceId,
329
+ name: "artifact.download",
330
+ action: "describe",
331
+ });
332
+ assert.equal(describedArtifact.isError, undefined);
333
+ assert.deepEqual(describedArtifact.structuredContent.capability.transport, {
334
+ nativeFileArgument: "file",
335
+ gatewayParameter: "file",
336
+ });
337
+ }
258
338
  const unknownCapability = callTool(oauth.accessToken, sessionId, 81, "capability", {
259
339
  workspaceId,
260
340
  name: "unknown.capability",
@@ -267,6 +347,7 @@ try {
267
347
  assert.deepEqual(capabilityGuides.map((guide) => guide.name), [
268
348
  "lifecycle-hooks",
269
349
  "managed-worktrees",
350
+ "artifacts-review",
270
351
  "host-integration",
271
352
  "shell-processes",
272
353
  ]);
@@ -292,6 +373,19 @@ try {
292
373
  assert.match(read.structuredContent.result, /forgerelay 7677 acceptance/);
293
374
  pass("write + read", JSON.stringify(read.structuredContent));
294
375
 
376
+ const reviewed = callTool(oauth.accessToken, sessionId, 83, "capability", {
377
+ workspaceId,
378
+ name: "review.changes",
379
+ action: "run",
380
+ arguments: {},
381
+ });
382
+ assert.equal(reviewed.isError, undefined);
383
+ assert.match(reviewed.structuredContent.result.result, /Changed 1 file/);
384
+ assert.equal(reviewed._meta?.tool, "capability");
385
+ assert.equal(reviewed._meta?.card?.capabilityName, "review.changes");
386
+ assert.match(reviewed._meta?.card?.payload?.patch ?? "", /acceptance\.txt/);
387
+ pass("review.changes", "Capability Gateway produced the aggregate review card");
388
+
295
389
  const shell = callTool(oauth.accessToken, sessionId, 6, "bash", {
296
390
  workspaceId,
297
391
  command: "printf debug-bash-ok",
@@ -549,12 +643,16 @@ function mcpRequest(accessToken, sessionId, request) {
549
643
  return { response, message: parseMcpMessage(response.body, request.id) };
550
644
  }
551
645
 
552
- function callTool(accessToken, sessionId, id, name, args) {
646
+ function callTool(accessToken, sessionId, id, name, args, meta) {
553
647
  const message = mcpRequest(accessToken, sessionId, {
554
648
  jsonrpc: "2.0",
555
649
  id,
556
650
  method: "tools/call",
557
- params: { name, arguments: args },
651
+ params: {
652
+ name,
653
+ arguments: args,
654
+ ...(meta ? { _meta: meta } : {}),
655
+ },
558
656
  }).message;
559
657
  assert.equal(message.id, id);
560
658
  assert.ok(message.result, `tool ${name} did not return a result`);