@akira-tl/forgerelay 0.2.6 → 0.3.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.
@@ -123,6 +123,35 @@ MCP clients discover metadata from:
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,
128
+ `open_workspace` returns a `capabilityFingerprint` containing the package
129
+ version, tool mode, and stable semantic capability names. The fingerprint also
130
+ reports enabled optional domains such as subagent profile discovery, native
131
+ artifact download, MCP App UI, or aggregate `show_changes` review when those
132
+ features are actually available; it remains a semantic summary rather than a
133
+ copy of `tools/list`.
134
+
135
+ Bootstrap responses also return `capabilityGuides`, which are compact descriptors
136
+ for built-in ForgeRelay documentation that the Agent can explicitly load with
137
+ `read` when a task needs that domain. Current built-in domains cover lifecycle
138
+ Hooks, managed worktrees, subagents, artifact/change-review workflows, Host/OAuth/
139
+ MCP App integration, and long-running shell/PTY/process behavior. Optional-domain
140
+ descriptors such as `subagents` and `artifacts-review` are advertised only when
141
+ the corresponding feature is enabled, so disabled features do not add bootstrap
142
+ context.
143
+
144
+ 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
+ fingerprint reports a capability that is missing from the Host's current tool
148
+ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integration
149
+ or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
150
+ force a Host to invalidate its cached schema.
151
+
152
+ `rename` is the canonical move/rename primitive for files and directories; there
153
+ is no separate `move` MCP tool.
154
+
126
155
  Codex-mode commands run without a PTY by default. `tty: true` enables interactive
127
156
  programs when the optional `node-pty` dependency is available.
128
157
 
@@ -182,7 +211,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
182
211
  "event": "BeforeTool",
183
212
  "matcher": {
184
213
  "tool": "bash",
185
- "commandRegex": "^git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+$"
214
+ "commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
186
215
  },
187
216
  "command": "npm run release:verify",
188
217
  "timeoutSeconds": 300,
@@ -226,7 +255,7 @@ forgerelay hooks check --project /path/to/project
226
255
  | 字段 | 匹配方式 |
227
256
  | --- | --- |
228
257
  | `tool` | 精确匹配 MCP tool 名称。 |
229
- | `commandRegex` | 对 tool payload 中的 `command` 做 JavaScript 正则匹配。 |
258
+ | `commandRegex` | 对 tool payload 中的 `command` 做 JavaScript 正则匹配;命中后 Hook 收到的 `payload.command` 是实际匹配片段,完整原命令在片段不等于整串命令时保留为 `payload.originalCommand`。 |
230
259
  | `pathRegex` | 对 payload 中的 `path` 或 `paths` 做正则匹配。 |
231
260
  | `provider` | 精确匹配 subagent provider。 |
232
261
  | `workspaceMode` | `checkout` 或 `worktree`。 |
@@ -316,7 +345,8 @@ When subagents are enabled, profiles are discovered from:
316
345
  - active legacy config directory `~/.devspace/agents/*.md` when reused;
317
346
  - project `.devspace/agents/*.md` for migration compatibility.
318
347
 
319
- The bundled `subagent-delegation` skill teaches the current CLI workflow:
348
+ The ForgeRelay-owned `subagents` capability guide teaches the current CLI
349
+ workflow on demand:
320
350
 
321
351
  ```bash
322
352
  forgerelay agents ls
@@ -324,6 +354,11 @@ forgerelay agents run <profile-or-provider-or-id> "<prompt>"
324
354
  forgerelay agents show <id>
325
355
  ```
326
356
 
357
+ 0.3 no longer auto-discovers or seeds the package's historical bundled
358
+ `subagent-delegation` Skill for new setups. An existing or user-authored Skill
359
+ with that name remains an ordinary Skill and is still discovered from the normal
360
+ Skill paths when subagents are enabled; ForgeRelay does not delete or rewrite it.
361
+
327
362
  ## Logging
328
363
 
329
364
  | Variable | Default |
@@ -334,7 +369,7 @@ forgerelay agents show <id>
334
369
  | `FORGERELAY_LOG_ASSETS` | `0` |
335
370
  | `FORGERELAY_LOG_TOOL_CALLS` | `1` |
336
371
  | `FORGERELAY_LOG_SHELL_COMMANDS` | `1` in `pretty`, `0` in `json` |
337
- | `FORGERELAY_TRUST_PROXY` | `0` |
372
+ | `FORGERELAY_TRUST_PROXY` | auto: `1` only for loopback bind + non-loopback public URL; otherwise `0` |
338
373
 
339
374
  `pretty` is the human-facing local console format. It uses terminal-aware color,
340
375
  short timestamps, workspace-first context, and compact operation results while
@@ -351,6 +386,14 @@ overridden, JSON mode preserves request logging and omits shell command previews
351
386
  `FORGERELAY_LOG_REQUESTS` and `FORGERELAY_LOG_SHELL_COMMANDS` always override
352
387
  these format-specific defaults when set.
353
388
 
389
+ When ForgeRelay binds to loopback (`127.0.0.1`, `::1`, or `localhost`) but is
390
+ configured with a non-loopback public URL, it automatically trusts exactly one
391
+ upstream proxy hop. This matches the normal tunnel/reverse-proxy topology and
392
+ keeps OAuth rate limiting aligned with Express client-IP resolution. Set
393
+ `FORGERELAY_TRUST_PROXY=0` to disable this inference, or `=1` to enable one-hop
394
+ trust explicitly. ForgeRelay never auto-enables proxy trust when binding to
395
+ `0.0.0.0` or another directly reachable interface.
396
+
354
397
  ## Environment-only example
355
398
 
356
399
  ```bash
package/docs/debugging.md CHANGED
@@ -60,7 +60,7 @@ The acceptance checks:
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
62
  6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, canonical `processId` plus the deprecated `sessionId` compatibility alias, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-workspace schema, and MCP App tool metadata;
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`, CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
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
64
  8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessManager`, and a deliberate failed `edit`;
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_worktree`;
package/docs/roadmap.md CHANGED
@@ -102,7 +102,79 @@ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生
102
102
  - 后台命令句柄以 `processId` / process handle 为 canonical 名称,旧 `sessionId` 在 0.2.x 保留 deprecated alias 兼容窗口;
103
103
  - 为 stateless MCP transport 做准备,同时保留旧协议兼容 adapter,避免把 transport 生命周期重新提升成 ForgeRelay 会话模型。
104
104
 
105
- ## 0.3 — LSP code intelligence v1
105
+ ## 0.3 — MCP loading 与渐进式能力披露
106
+
107
+ 0.3 的目标是把 ForgeRelay 的 MCP interface 做成一个深而稳定的模型接口:普通编码 primitive 始终直接可见,低频知识与低频 action 都按需披露,新增 ForgeRelay 能力不再线性扩大 Host 首次加载的 `tools/list` 与 instructions。
108
+
109
+ 0.3.0 已完成第一阶段:压缩 server instructions、由 `open_workspace` 返回 version/capability fingerprint、通过 advertised path + `read` 按需加载 ForgeRelay-owned capability guide,并用 fingerprint 与 Host `tools/list` 的差异诊断 stale Host metadata。Capability guide 与 Skill 的语义所有权保持区分:Skill 描述用户、项目或生态工作流;Capability guide 描述 ForgeRelay 自身、与版本绑定的产品能力。
110
+
111
+ 0.3 后续阶段采用 ADR-0002 的接口形状。Canonical Core tool surface 最终固定为:
112
+
113
+ ```text
114
+ open_workspace
115
+ close_workspace
116
+ read
117
+ write
118
+ edit
119
+ rename
120
+ delete
121
+ bash
122
+ capability
123
+ ```
124
+
125
+ 其中 `open_workspace` 负责 Workspace 生命周期入口与轻量 Capability catalog;`capability` 是唯一低频 Capability gateway;`bash` 同时承担命令启动与后续 process interaction;Managed worktree 是 Workspace 的 backing mode,由 `close_workspace` 统一完成关闭/finalize lifecycle。Capability registry 只能暴露显式注册、带输入约束、可用性和 guide metadata 的 ForgeRelay capability,不能退化成任意 RPC、URL dispatcher 或 shell 后门。
126
+
127
+ ### 0.3.1 — MCP App 与诊断补丁
128
+
129
+ 在不改变主 tool surface 的前提下先修 0.3.0 发布后真实 Host 暴露的问题:
130
+
131
+ - 为 MCP App resource 补齐 Host submission 所需的 widget/app domain metadata,并保持现有 CSP、content-hash URI 与 compatibility resource contract;
132
+ - `forgerelay doctor` 显示解析后的 MCP 运行形态,例如 tool mode、widgets、public URL、proxy trust 与可选 capability 开关,避免“功能代码存在但当前实例未启用”只能靠源码排查;
133
+ - 补齐 release/Host integration 的诊断与验收用例,但不在这一版重塑 tool schema。
134
+
135
+ ### 0.3.2 — Capability Registry 与 Gateway
136
+
137
+ 建立新的低频 action seam,但保留现有公开工具作为迁移兼容:
138
+
139
+ - 新建 ForgeRelay-owned Capability registry,每项至少声明稳定 name、简短 description、availability、input contract、guide metadata 与 handler;
140
+ - 新增唯一 MCP tool `capability`,提供紧凑的 `describe` / `run` 语义;
141
+ - `open_workspace` 返回轻量 Capability catalog,只做发现,不复制完整 schema、示例或 guide 正文;
142
+ - Agent 已熟悉某项 capability 时可以直接执行;不熟悉时先 `describe`,再按返回的 guide path 使用 `read` 获取详细说明;
143
+ - 选择一组低风险、当前主要依赖 CLI 的检查型能力作为 tracer bullet,验证 registry、Hooks、日志、错误和 Host card contract,而不是一开始迁移所有功能。
144
+
145
+ ### 0.3.3 — 低频 Action 迁入 Gateway
146
+
147
+ 用真实现有能力验证 Gateway 能承载持续扩展,而不是只做一层转发:
148
+
149
+ - 将 change review 收口为如 `review.changes` 的 registered capability;
150
+ - 将 native artifact ingress 收口为如 `artifact.download` 的 registered capability;
151
+ - 适合 Agent 主动调用的 Hook inspection/check 等低频操作进入同一 namespace model;
152
+ - Capability guide 与 catalog/registry 建立一一可追踪关系,availability 由运行时条件决定;
153
+ - `show_changes`、`download_artifact` 等旧 dedicated MCP tools 在迁移窗口内只作为兼容入口,不再作为长期接口设计。
154
+
155
+ ### 0.3.4 — Workspace 与 Process 生命周期收敛
156
+
157
+ 移除两个泄漏内部实现的 public lifecycle tool:
158
+
159
+ - `bash` 成为 Process Manager 的唯一公开 interface;`action="run"` 启动命令,`action="process"` 使用 `processId` 查看、等待、输入、调整 PTY 或中断已有进程;内部 ProcessManager 可以继续保留更细的方法,但 Host 不再需要学习 `write_stdin`;
160
+ - `close_workspace` 成为唯一 workspace 关闭入口;checkout 直接释放,managed-worktree-backed Workspace 在同一接口内执行 BeforeWorktreeClose、commit/integrate/cleanup、AfterWorktreeClose 并关闭 Workspace;
161
+ - 从 canonical MCP surface 删除 `write_stdin` 与 `close_worktree`,同时清理对应 server instructions、fingerprint 和 capability guide 中的旧心智模型;
162
+ - 保留 `processId` 作为运行中进程的 opaque handle,保留 worktree 作为 Workspace 的可观察 backing metadata,而不是第二套 Host lifecycle。
163
+
164
+ ### 0.3.5 — Canonical MCP Surface 收口
165
+
166
+ 完成 0.3 的接口稳定化与真实 Host 验收:
167
+
168
+ - regular ForgeRelay MCP surface 收口为 9 个 canonical tools;`minimal/full` 不再通过增减 `grep/glob/ls` 改变主产品心智模型,搜索与目录检查可由 `bash` 承担;
169
+ - 评估并隔离 `codex` compatibility surface,使其作为明确 adapter 存在,而不是反向定义 ForgeRelay canonical interface;
170
+ - 删除已经完成迁移的 dedicated low-frequency tool aliases,确保新增 Capability 不再扩大常驻 tool count;
171
+ - 简化 fingerprint,使其用于版本/运行时能力摘要与 stale-Host 诊断,而不是重新枚举 tool implementation;
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。
174
+
175
+ 必要安全语义始终留在 Core tool interface、Capability contract 或自动 Hook report 中;渐进式披露不能成为隐藏权限、隐式 autonomous workflow 或绕过 allowed roots/auth 的机制。`rename` 继续作为文件和目录 move/rename 的统一 primitive。
176
+
177
+ ## 0.4 — LSP code intelligence v1
106
178
 
107
179
  LSP is moderate implementation complexity if ForgeRelay does not become a
108
180
  language-server installer.
@@ -110,7 +182,7 @@ language-server installer.
110
182
  The first version should launch only language servers already available on the
111
183
  user's machine or explicitly configured by the user/project.
112
184
 
113
- During 0.3 development, MCP App UI hardening can land alongside the LSP work when
185
+ During 0.4 development, MCP App UI hardening can land alongside the LSP work when
114
186
  it does not distort the code-intelligence scope. In particular, evaluate a more
115
187
  self-contained template/bootstrap bundle, reduce avoidable external chunk fetches,
116
188
  and keep the current content-hash/compatibility-resource contract intact. This is
@@ -125,47 +197,19 @@ Initial operations:
125
197
  - workspace symbols;
126
198
  - hover/type information.
127
199
 
128
- Prefer one deep MCP capability such as:
129
-
130
- ```text
131
- code_intelligence({
132
- workspaceId,
133
- operation,
134
- path,
135
- line,
136
- column,
137
- query
138
- })
139
- ```
140
-
141
- rather than one MCP tool per language or language-server method.
200
+ Expose code intelligence through the Capability Gateway established in 0.3 rather than adding another top-level MCP tool. A representative registered capability may look like `code.intelligence`, with its language-server operation/path/position/query fields carried inside the capability arguments. The exact LSP contract remains 0.4 work; the stable Core tool surface does not change per language or language-server method.
142
201
 
143
202
  Candidate servers include `typescript-language-server`/tsserver, Pyright,
144
203
  `rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
145
204
  commands/configuration as external dependencies.
146
205
 
147
- ## 0.4 — First-class subagent MCP
206
+ ## 0.5 — First-class subagent MCP
148
207
 
149
208
  ForgeRelay already owns provider adapters and resumable local agent sessions.
150
209
  The next step is to remove the current `bash -> forgerelay agents ...` indirection
151
210
  for MCP hosts.
152
211
 
153
- A compact interface should reuse the existing provider adapter registry:
154
-
155
- ```text
156
- subagent({
157
- action: "run" | "list" | "show" | "cancel",
158
- workspaceId,
159
- profile,
160
- provider,
161
- prompt,
162
- agentId
163
- })
164
- ```
165
-
166
- The parent agent chooses an available provider/profile such as Codex or Claude.
167
- ForgeRelay launches, tracks, resumes, and cancels the provider-backed worker when
168
- the underlying provider supports those operations.
212
+ First-class subagent operations should reuse the Capability Gateway established in 0.3 rather than add another top-level MCP tool. The exact registered names, action semantics and provider/session contract remain 0.5 design work. The parent agent will continue choosing from available provider/profile metadata while ForgeRelay owns provider-backed worker lifecycle state.
169
213
 
170
214
  This is intentionally provider-backed delegation, not an attempt to emulate a
171
215
  host-native subagent implementation.
package/docs/security.md CHANGED
@@ -28,6 +28,17 @@ Filesystem-oriented tools canonicalize existing path segments before access so
28
28
  symlinks inside either the workspace or OS temp directory cannot escape to
29
29
  arbitrary filesystem locations.
30
30
 
31
+ `read` has one narrow additional path class for explicitly advertised documents.
32
+ `open_workspace` may advertise a Skill entry file or a ForgeRelay capability
33
+ Guide outside the normal workspace/temp roots. Only that advertised entry file is
34
+ readable initially; after it is read, files inside that advertised document's
35
+ own directory may be read as supporting material. This does not expand write,
36
+ edit, rename, delete, shell working-directory, or workspace-open roots.
37
+
38
+ Capability Guides are ForgeRelay-owned, versioned package documentation. Their
39
+ paths are surfaced by the running server rather than guessed by the Agent. This
40
+ keeps progressive disclosure from becoming a general arbitrary-file read escape.
41
+
31
42
  ## Owner-password OAuth
32
43
 
33
44
  New installations store local configuration in:
@@ -100,13 +111,19 @@ for dynamically crossing such a boundary.
100
111
  The Agent-facing shell contract therefore does not ban every command that can
101
112
  change files. Commands may modify ordinary project files when that is a natural
102
113
  part of the user's requested development task, including package managers,
103
- generators, formatters, and similar tooling. The contract does prohibit shell
104
- mutation of security- or privilege-sensitive operating-system files and
105
- credential material such as `/etc/sudoers`, `/etc/passwd`, `/etc/shadow`, PAM or
106
- authentication policy, SSH private keys, and equivalent privileged targets.
107
- Configuration files may be changed through shell only when the user's request
108
- explicitly calls for that configuration change rather than merely making it a
109
- convenient implementation detail.
114
+ generators, formatters, and similar tooling. Shell commands may also perform
115
+ external device or hardware mutations when the user's current request explicitly
116
+ asks for the actual device-changing operation. ForgeRelay does not assume a
117
+ particular flashing protocol, transport, device path, or firmware workflow. A
118
+ check, audit, probe, backup, verification, dry-run, or build-only request must not
119
+ be treated as authorization for a later persistent hardware write.
120
+
121
+ The contract does prohibit shell mutation of security- or privilege-sensitive
122
+ operating-system files and credential material such as `/etc/sudoers`,
123
+ `/etc/passwd`, `/etc/shadow`, PAM or authentication policy, SSH private keys, and
124
+ equivalent privileged targets. Configuration files may be changed through shell
125
+ only when the user's request explicitly calls for that configuration change
126
+ rather than merely making it a convenient implementation detail.
110
127
 
111
128
  This is an Agent execution policy, not an OS-level sandbox or command parser.
112
129
  Operators that need a stronger project-specific runtime gate can use blocking
package/docs/setup.md CHANGED
@@ -120,7 +120,9 @@ npx @akira-tl/forgerelay doctor
120
120
  ```
121
121
 
122
122
  The doctor command reports the resolved config, Node runtime, platform, Git,
123
- Bash, public URL, allowed hosts, and native SQLite dependency status.
123
+ Bash, public URL, allowed hosts, native SQLite dependency status, and the MCP
124
+ shape ForgeRelay will expose: tool mode, widget mode, one-hop proxy trust, and
125
+ whether optional artifact, subagent, and Skill capabilities are enabled.
124
126
 
125
127
  ## Running from a local checkout
126
128
 
@@ -168,7 +168,9 @@ npm publishing token.
168
168
  git push origin v0.2.0
169
169
  ```
170
170
 
171
- The tag push is the publication action.
171
+ The tag push is the publication action. The release workflow publishes npm only after cloud CI passes, then extracts the matching `CHANGELOG.md` release section as the GitHub Release body. Keep `Unreleased` user-facing and structured (`Added`, `Changed`, `Fixed`, `Security`) because those notes are what users see on the Release page.
172
+
173
+ Project release Hooks match the stable tag-push command as a substring of the ForgeRelay shell request. A compound command is allowed: when `commandRegex` matches `git push origin vX.Y.Z`, the Hook receives that matched command as `FORGERELAY_HOOK_PAYLOAD.command` and retains the complete shell request as `originalCommand` when they differ.
172
174
 
173
175
  ## Attribution guardrails
174
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.6",
3
+ "version": "0.3.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",
@@ -24,6 +24,7 @@
24
24
  "examples",
25
25
  "scripts",
26
26
  "skills",
27
+ "capabilities",
27
28
  "README.md",
28
29
  "CHANGELOG.md",
29
30
  "NOTICE.md"
@@ -34,14 +35,14 @@
34
35
  },
35
36
  "scripts": {
36
37
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
37
- "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json",
38
+ "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json && node scripts/ensure-cli-executable.mjs",
38
39
  "build:app": "vite build",
39
40
  "dev": "node scripts/debug/serve.mjs",
40
41
  "debug:serve": "node scripts/debug/serve.mjs",
41
42
  "debug:accept": "node scripts/debug/accept.mjs",
42
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
43
44
  "start": "node dist/cli.js serve",
44
- "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
47
  "release:check": "node scripts/release-version.mjs check",
47
48
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -45,6 +45,18 @@ const { env } = createDebugEnvironment({
45
45
  hookLog,
46
46
  widgets: "full",
47
47
  });
48
+ const doctor = spawnSync(process.execPath, ["--import", "tsx", "src/cli.ts", "doctor"], {
49
+ cwd: repoRoot,
50
+ env,
51
+ encoding: "utf8",
52
+ });
53
+ assert.equal(doctor.status, 0, doctor.stderr);
54
+ assert.match(doctor.stdout, /Public base URL: http:\/\/127\.0\.0\.1:7677/);
55
+ assert.match(doctor.stdout, /Tool mode: full/);
56
+ assert.match(doctor.stdout, /Widgets: full/);
57
+ assert.match(doctor.stdout, /Trust proxy: off/);
58
+ pass("doctor resolved MCP shape", "public URL + tool/widgets/proxy state");
59
+
48
60
  const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
49
61
  cwd: repoRoot,
50
62
  env,
@@ -93,6 +105,9 @@ try {
93
105
  assert.match(serverInstructions, /Shell commands may modify ordinary project files/);
94
106
  assert.match(serverInstructions, /\/etc\/sudoers/);
95
107
  assert.match(serverInstructions, /configuration files through shell only when the user's request explicitly calls for that configuration change/);
108
+ assert.match(serverInstructions, /managed-worktrees capability guide/);
109
+ assert.ok(serverInstructions.length < 3_000, `server instructions should stay compact, got ${serverInstructions.length} characters`);
110
+ assert.doesNotMatch(serverInstructions, /fast-forwards the original target branch/);
96
111
  assert.doesNotMatch(serverInstructions, /Do not create or modify files with bash/);
97
112
  pass(
98
113
  "MCP initialize",
@@ -118,8 +133,10 @@ try {
118
133
  assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
119
134
  }
120
135
  const bashTool = tools.find((tool) => tool.name === "bash");
121
- assert.match(bashTool?.description ?? "", /may modify ordinary project files/);
122
- assert.match(bashTool?.description ?? "", /\/etc\/sudoers/);
136
+ assert.match(bashTool?.description ?? "", /local user's authority/);
137
+ assert.doesNotMatch(bashTool?.description ?? "", /may modify ordinary project files/);
138
+ assert.doesNotMatch(bashTool?.description ?? "", /\/etc\/sudoers/);
139
+ assert.doesNotMatch(bashTool?.description ?? "", /external device or hardware mutations/);
123
140
  assert.doesNotMatch(bashTool?.description ?? "", /Do not use bash to create, move, rename, or delete project files/);
124
141
  assert.equal(bashTool?.inputSchema?.properties?.timeout, undefined);
125
142
  assert.match(bashTool?.description ?? "", /waits up to 300 seconds/);
@@ -138,6 +155,8 @@ try {
138
155
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
139
156
  assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
140
157
  assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
158
+ assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityFingerprint);
159
+ assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
141
160
  const templateUri = bashTool?._meta?.ui?.resourceUri;
142
161
  assert.match(
143
162
  templateUri ?? "",
@@ -154,7 +173,9 @@ try {
154
173
  method: "resources/list",
155
174
  params: {},
156
175
  }).message.result.resources;
157
- assert.ok(resources.some((resource) => resource.uri === templateUri));
176
+ const currentResource = resources.find((resource) => resource.uri === templateUri);
177
+ assert.ok(currentResource);
178
+ assert.equal(currentResource._meta?.ui?.domain, debugBaseUrl);
158
179
  assert.ok(resources.some((resource) => resource.uri === "ui://forgerelay/workspace-app.html"));
159
180
 
160
181
  const resourceTemplates = mcpRequest(oauth.accessToken, sessionId, {
@@ -178,6 +199,7 @@ try {
178
199
  assert.equal(template.uri, templateUri);
179
200
  assert.equal(template.mimeType, "text/html;profile=mcp-app");
180
201
  assert.match(template.text ?? "", /<script type="module" crossorigin src="[^"]+\/mcp-app-assets\//);
202
+ assert.equal(template._meta?.ui?.domain, debugBaseUrl);
181
203
  assert.ok(template._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
182
204
  const scriptUrl = template.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
183
205
  assert.ok(scriptUrl);
@@ -203,7 +225,35 @@ try {
203
225
  assert.match(workspaceId, /^ws_/);
204
226
  assert.equal(opened.structuredContent.root, checkoutWorkspace);
205
227
  assert.equal(opened.structuredContent.mode, "checkout");
206
- pass("open_workspace", `${workspaceId} -> ${opened.structuredContent.root}`);
228
+ assert.deepEqual(opened.structuredContent.capabilityFingerprint, {
229
+ version: packageJson.version,
230
+ toolMode: "full",
231
+ capabilities: [
232
+ "workspace.close",
233
+ "worktree.managed",
234
+ "filesystem.rename-move",
235
+ "filesystem.delete",
236
+ "process.write-stdin",
237
+ "hooks.lifecycle",
238
+ "capability-guides.read",
239
+ "inspection.search-tools",
240
+ "ui.mcp-app",
241
+ ],
242
+ });
243
+ const capabilityGuides = opened.structuredContent.capabilityGuides;
244
+ assert.deepEqual(capabilityGuides.map((guide) => guide.name), [
245
+ "lifecycle-hooks",
246
+ "managed-worktrees",
247
+ "host-integration",
248
+ "shell-processes",
249
+ ]);
250
+ const hooksGuide = callTool(oauth.accessToken, sessionId, 78, "read", {
251
+ workspaceId,
252
+ path: capabilityGuides[0].path,
253
+ });
254
+ assert.match(hooksGuide.structuredContent.result, /BeforeTool/);
255
+ assert.match(hooksGuide.structuredContent.result, /BeforeWorktreeClose/);
256
+ pass("open_workspace", `${workspaceId} -> fingerprint + ${capabilityGuides.length} capability guides`);
207
257
 
208
258
  const written = callTool(oauth.accessToken, sessionId, 4, "write", {
209
259
  workspaceId,
@@ -620,8 +670,9 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
620
670
  [
621
671
  'import { writeFileSync } from "node:fs";',
622
672
  'const payload = process.env.FORGERELAY_HOOK_PAYLOAD ?? "{}";',
673
+ 'const parsed = JSON.parse(payload);',
623
674
  'writeFileSync("release-ci-ran.txt", payload);',
624
- 'if (JSON.parse(payload).command === "git push origin v0.2.1") process.exit(17);',
675
+ 'if (parsed.command === "git push origin v0.2.1") process.exit(17);',
625
676
  "",
626
677
  ].join("\n"),
627
678
  );
@@ -629,7 +680,7 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
629
680
  join(releaseProject, ".forgerelay", "hooks", "release-tag-local-ci.json"),
630
681
  JSON.stringify({
631
682
  event: "BeforeTool",
632
- matcher: { tool: "bash", commandRegex: "^git push origin v0\\.2\\.[01]$" },
683
+ matcher: { tool: "bash", commandRegex: "git push origin v0\\.2\\.[01]" },
633
684
  command: "node .forgerelay/release-check.mjs",
634
685
  timeoutSeconds: 30,
635
686
  report: true,
@@ -647,11 +698,17 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
647
698
 
648
699
  const pushed = callTool(accessToken, sessionId, 12, "bash", {
649
700
  workspaceId: releaseWorkspaceId,
650
- command: "git push origin v0.2.0",
701
+ command: "git status --short && git push origin v0.2.0 && echo release-pushed",
651
702
  });
652
703
  assert.equal(pushed.isError, undefined);
653
704
  assert.match(toolText(pushed), /release-tag-local-ci \(BeforeTool, project\) passed/);
654
705
  assert.ok(existsSync(join(releaseProject, "release-ci-ran.txt")));
706
+ assert.deepEqual(JSON.parse(readFileSync(join(releaseProject, "release-ci-ran.txt"), "utf8")), {
707
+ tool: "bash",
708
+ command: "git push origin v0.2.0",
709
+ workingDirectory: ".",
710
+ originalCommand: "git status --short && git push origin v0.2.0 && echo release-pushed",
711
+ });
655
712
  assert.equal(
656
713
  gitOutput(releaseRemote, ["rev-parse", "refs/tags/v0.2.0"], { gitDir: true }),
657
714
  gitOutput(releaseProject, ["rev-parse", "v0.2.0"]),
@@ -659,7 +716,7 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
659
716
 
660
717
  const blocked = callTool(accessToken, sessionId, 13, "bash", {
661
718
  workspaceId: releaseWorkspaceId,
662
- command: "git push origin v0.2.1",
719
+ command: "git status --short && git push origin v0.2.1 && echo should-not-run",
663
720
  });
664
721
  assert.equal(blocked.isError, true);
665
722
  assert.match(toolText(blocked), /release-tag-local-ci.*failed/);
@@ -0,0 +1,12 @@
1
+ import { chmodSync, statSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ if (process.platform !== "win32") {
5
+ const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
6
+ const currentMode = statSync(cliPath).mode;
7
+ chmodSync(cliPath, currentMode | 0o111);
8
+
9
+ if ((statSync(cliPath).mode & 0o111) === 0) {
10
+ throw new Error(`ForgeRelay CLI is not executable after build: ${cliPath}`);
11
+ }
12
+ }
@@ -46,8 +46,20 @@ switch (command) {
46
46
  await prepareRelease(state, incrementVersion(state.pkg.version, bump), dryRun);
47
47
  break;
48
48
  }
49
+ case "notes": {
50
+ checkState(state);
51
+ if (!value) fail("usage: node scripts/release-version.mjs notes vX.Y.Z");
52
+ const version = value.startsWith("v") ? value.slice(1) : value;
53
+ if (!stableVersionPattern.test(version)) {
54
+ fail(`release notes version ${JSON.stringify(value)} must be vX.Y.Z or X.Y.Z`);
55
+ }
56
+ const body = getReleaseBody(state.changelog, version);
57
+ if (!body) fail(`CHANGELOG.md has no release notes for ${version}`);
58
+ process.stdout.write(`${body}\n`);
59
+ break;
60
+ }
49
61
  default:
50
- fail(`unknown release command ${JSON.stringify(command)}; expected check, tag, or next`);
62
+ fail(`unknown release command ${JSON.stringify(command)}; expected check, tag, next, or notes`);
51
63
  }
52
64
 
53
65
  async function readState() {
@@ -182,6 +194,17 @@ function getUnreleasedBody(changelog) {
182
194
  return changelog.slice(bodyStart, bodyEnd).trim();
183
195
  }
184
196
 
197
+ function getReleaseBody(changelog, version) {
198
+ const heading = `## [${version}]`;
199
+ const headingIndex = changelog.indexOf(heading);
200
+ if (headingIndex < 0) return "";
201
+ const headingEnd = changelog.indexOf("\n", headingIndex);
202
+ const bodyStart = headingEnd < 0 ? changelog.length : headingEnd + 1;
203
+ const nextHeadingIndex = changelog.indexOf("\n## [", bodyStart);
204
+ const bodyEnd = nextHeadingIndex < 0 ? changelog.length : nextHeadingIndex;
205
+ return changelog.slice(bodyStart, bodyEnd).trim();
206
+ }
207
+
185
208
  function incrementVersion(version, bump) {
186
209
  const match = version.match(stableVersionPattern);
187
210
  if (!match) fail(`cannot increment invalid stable version ${JSON.stringify(version)}`);