@akira-tl/forgerelay 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/capabilities/host-integration/GUIDE.md +1 -1
- package/capabilities/lifecycle-hooks/GUIDE.md +3 -1
- package/dist/capability-registry.js +118 -0
- package/dist/cli.js +7 -0
- package/dist/hook-cli.js +12 -0
- package/dist/hooks.js +30 -14
- package/dist/mcp/server-instructions.js +2 -1
- package/dist/server.js +147 -0
- package/docs/configuration.md +2 -2
- package/docs/debugging.md +1 -1
- package/docs/roadmap.md +64 -46
- package/docs/setup.md +3 -1
- package/docs/versioning.md +3 -1
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +58 -7
- package/scripts/release-version.mjs +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.2] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added a ForgeRelay-owned Capability Registry and the single `capability` MCP gateway with `describe` / `run`, stable dotted names, runtime availability, validated input contracts, guide metadata, and stable diagnostic error codes.
|
|
12
|
+
- `open_workspace` now returns a lightweight Capability catalog on every open. The first tracer capability, `hooks.check`, validates active global/project Hook configuration without requiring Agents to route through shell or the CLI.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Server instructions now direct Agents to use the Capability catalog for low-frequency actions, describing and reading only an unfamiliar capability's advertised guide before first use instead of preloading all low-frequency instructions.
|
|
17
|
+
|
|
18
|
+
## [0.3.1] - 2026-08-10
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Release-tag Hooks now treat `commandRegex` as a command extractor as well as a filter: a matching substring becomes Hook `payload.command`, while a different full shell request is preserved as `payload.originalCommand`. This lets stable tag pushes trigger local release gates even when an Agent wraps them in a compound shell command.
|
|
23
|
+
- GitHub Release pages now use the matching `CHANGELOG.md` version section as their release notes instead of relying only on generated compare notes.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- MCP App resources now advertise a unique `_meta.ui.domain` derived from the resolved public deployment origin while preserving existing CSP, content-hashed template identity, and legacy/historical compatibility resources.
|
|
28
|
+
- `forgerelay doctor` now reports the resolved MCP runtime shape, including public base URL, tool/widget modes, proxy trust, and optional artifact/subagent/Skill capability switches.
|
|
29
|
+
|
|
7
30
|
## [0.3.0] - 2026-08-10
|
|
8
31
|
|
|
9
32
|
### Added
|
|
@@ -53,7 +53,7 @@ ForgeRelay 正常会广告 content-hashed:
|
|
|
53
53
|
ui://forgerelay/workspace-app-<hash>.html
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
`resources/read` 应返回 `text/html;profile=mcp-app`,并且 HTML 引用的 `/mcp-app-assets/` 资源必须可达。ForgeRelay 还保留 legacy `ui://forgerelay/workspace-app.html` 和历史 `workspace-app-*.html` 兼容指针,以容忍 Host 暂时持有旧 metadata snapshot。
|
|
56
|
+
`resources/list` 与 `resources/read` 的 MCP App metadata 都应包含唯一 `_meta.ui.domain`,其值来自 resolved `publicBaseUrl` 的 origin;CSP 仍使用完整 public base URL 约束资源与连接域。`resources/read` 应返回 `text/html;profile=mcp-app`,并且 HTML 引用的 `/mcp-app-assets/` 资源必须可达。ForgeRelay 还保留 legacy `ui://forgerelay/workspace-app.html` 和历史 `workspace-app-*.html` 兼容指针,以容忍 Host 暂时持有旧 metadata snapshot。
|
|
57
57
|
|
|
58
58
|
需要 live trace 时,可用:
|
|
59
59
|
|
|
@@ -36,4 +36,6 @@ Hook report 会随工具结果返回给 Host/Agent。`report: false` 只隐藏
|
|
|
36
36
|
|
|
37
37
|
## 检查入口
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Agent 打开工作区后会在 Capability catalog 中看到 `hooks.check`。已经熟悉 contract 时可直接通过 `capability` 执行;不熟悉时先 `capability(action="describe")` 查看参数与本指南路径,再按需读取本指南。`hooks.check` 是只读检查,只接受空参数对象,并返回当前生效的全局/项目 Hook 数量;无效项目 Hook 会作为稳定的 capability execution error 返回。
|
|
40
|
+
|
|
41
|
+
CLI 仍保留给人工终端或兼容工作流:使用 `forgerelay hooks list` 查看已发现规则,使用 `forgerelay hooks check` 做只读校验。排查 Hook 时优先确认配置来源、event/matcher 是否命中、handler 的实际退出状态,以及 tool result 中的 Hook report。
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export class CapabilityError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "CapabilityError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class CapabilityRegistry {
|
|
11
|
+
definitions;
|
|
12
|
+
constructor(definitions) {
|
|
13
|
+
this.definitions = new Map(definitions.map((definition) => [definition.name, definition]));
|
|
14
|
+
}
|
|
15
|
+
catalog(context) {
|
|
16
|
+
return [...this.definitions.values()].map((definition) => {
|
|
17
|
+
const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
|
|
18
|
+
const availability = definition.availability(context);
|
|
19
|
+
const available = Boolean(guide) && availability.available;
|
|
20
|
+
const unavailableReason = !guide
|
|
21
|
+
? `Capability guide ${definition.guideName} is unavailable.`
|
|
22
|
+
: availability.reason;
|
|
23
|
+
return {
|
|
24
|
+
name: definition.name,
|
|
25
|
+
description: definition.description,
|
|
26
|
+
available,
|
|
27
|
+
...(!available && unavailableReason ? { unavailableReason } : {}),
|
|
28
|
+
guide: {
|
|
29
|
+
name: definition.guideName,
|
|
30
|
+
path: guide?.path ?? "",
|
|
31
|
+
readBeforeFirstUse: definition.readGuideBeforeFirstUse,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
describe(name, context) {
|
|
37
|
+
const definition = this.requireDefinition(name);
|
|
38
|
+
const catalogEntry = this.catalogEntry(definition, context);
|
|
39
|
+
const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
|
|
40
|
+
if (!guide) {
|
|
41
|
+
throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable: capability guide ${definition.guideName} is unavailable.`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...catalogEntry,
|
|
45
|
+
guide: {
|
|
46
|
+
...catalogEntry.guide,
|
|
47
|
+
description: guide.description,
|
|
48
|
+
whenToRead: guide.whenToRead,
|
|
49
|
+
},
|
|
50
|
+
inputSchema: z.toJSONSchema(definition.inputSchema, { target: "draft-7" }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async run(name, argumentsValue, context) {
|
|
54
|
+
const definition = this.requireDefinition(name);
|
|
55
|
+
const catalogEntry = this.catalogEntry(definition, context);
|
|
56
|
+
if (!catalogEntry.available) {
|
|
57
|
+
throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
|
|
58
|
+
}
|
|
59
|
+
const parsed = definition.inputSchema.safeParse(argumentsValue ?? {});
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
const details = parsed.error.issues
|
|
62
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "arguments"}: ${issue.message}`)
|
|
63
|
+
.join("; ");
|
|
64
|
+
throw new CapabilityError("invalid_arguments", `Invalid arguments for capability ${name}: ${details}`);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return await definition.run(parsed.data, context);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof CapabilityError)
|
|
71
|
+
throw error;
|
|
72
|
+
throw new CapabilityError("execution_failed", `Capability ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
requireDefinition(name) {
|
|
76
|
+
const definition = this.definitions.get(name);
|
|
77
|
+
if (!definition) {
|
|
78
|
+
throw new CapabilityError("unknown_capability", `Unknown capability: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
return definition;
|
|
81
|
+
}
|
|
82
|
+
catalogEntry(definition, context) {
|
|
83
|
+
const guide = context.guides.find((candidate) => candidate.name === definition.guideName);
|
|
84
|
+
const availability = definition.availability(context);
|
|
85
|
+
const available = Boolean(guide) && availability.available;
|
|
86
|
+
const unavailableReason = !guide
|
|
87
|
+
? `Capability guide ${definition.guideName} is unavailable.`
|
|
88
|
+
: availability.reason;
|
|
89
|
+
return {
|
|
90
|
+
name: definition.name,
|
|
91
|
+
description: definition.description,
|
|
92
|
+
available,
|
|
93
|
+
...(!available && unavailableReason ? { unavailableReason } : {}),
|
|
94
|
+
guide: {
|
|
95
|
+
name: definition.guideName,
|
|
96
|
+
path: guide?.path ?? "",
|
|
97
|
+
readBeforeFirstUse: definition.readGuideBeforeFirstUse,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function createCapabilityRegistry(dependencies) {
|
|
103
|
+
const hooksCheckInput = z.object({}).strict();
|
|
104
|
+
return new CapabilityRegistry([
|
|
105
|
+
{
|
|
106
|
+
name: "hooks.check",
|
|
107
|
+
description: "Validate the active ForgeRelay Hook configuration for this workspace.",
|
|
108
|
+
guideName: "lifecycle-hooks",
|
|
109
|
+
readGuideBeforeFirstUse: true,
|
|
110
|
+
inputSchema: hooksCheckInput,
|
|
111
|
+
availability: () => ({ available: true }),
|
|
112
|
+
run: async (_input, context) => ({
|
|
113
|
+
ok: true,
|
|
114
|
+
...await dependencies.inspectHooks(context.workspaceRoot),
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -222,7 +222,14 @@ async function runDoctor() {
|
|
|
222
222
|
try {
|
|
223
223
|
const config = loadConfig();
|
|
224
224
|
console.log(`Local MCP URL: http://${config.host}:${config.port}/mcp`);
|
|
225
|
+
console.log(`Public base URL: ${config.publicBaseUrl}`);
|
|
225
226
|
console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`);
|
|
227
|
+
console.log(`Tool mode: ${config.toolMode}`);
|
|
228
|
+
console.log(`Widgets: ${config.widgets}`);
|
|
229
|
+
console.log(`Trust proxy: ${config.logging.trustProxy ? "one hop" : "off"}`);
|
|
230
|
+
console.log(`Artifacts: ${config.artifactsEnabled ? "enabled" : "disabled"}`);
|
|
231
|
+
console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`);
|
|
232
|
+
console.log(`Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`);
|
|
226
233
|
console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
|
|
227
234
|
console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
|
|
228
235
|
}
|
package/dist/hook-cli.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { HOOK_EVENTS, loadProjectHookConfig, mergeHookConfigs, parseHookConfig, } from "./hooks.js";
|
|
3
3
|
import { loadForgeRelayFiles } from "./user-config.js";
|
|
4
|
+
export async function checkHookConfiguration(projectRoot, globalHooks = loadGlobalHooks()) {
|
|
5
|
+
const globalEntries = flattenHooks(globalHooks, "global");
|
|
6
|
+
const project = await loadProjectHookConfig(projectRoot);
|
|
7
|
+
if (project.diagnostic) {
|
|
8
|
+
throw new Error(`Hook check failed: ${project.diagnostic}`);
|
|
9
|
+
}
|
|
10
|
+
const projectEntries = flattenHooks(project.hooks, "project");
|
|
11
|
+
return {
|
|
12
|
+
globalHooks: globalEntries.length,
|
|
13
|
+
projectHooks: projectEntries.length,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
4
16
|
export async function runHooksCommand(args) {
|
|
5
17
|
const [subcommand, ...rest] = args;
|
|
6
18
|
if (!subcommand || ["help", "--help", "-h"].includes(subcommand)) {
|
package/dist/hooks.js
CHANGED
|
@@ -215,9 +215,12 @@ export class HookRunner {
|
|
|
215
215
|
const handlers = [
|
|
216
216
|
...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
|
|
217
217
|
...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
|
|
218
|
-
]
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
].flatMap(({ scope, rule }) => {
|
|
219
|
+
const matchedInvocation = matchHookRule(rule.matcher, invocation);
|
|
220
|
+
if (!matchedInvocation)
|
|
221
|
+
return [];
|
|
222
|
+
return rule.handlers.map((handler) => ({ scope, handler, invocation: matchedInvocation }));
|
|
223
|
+
});
|
|
221
224
|
const blocking = BLOCKING_EVENTS.has(event);
|
|
222
225
|
const executions = project.diagnostic
|
|
223
226
|
? [{
|
|
@@ -230,8 +233,8 @@ export class HookRunner {
|
|
|
230
233
|
error: project.diagnostic,
|
|
231
234
|
}]
|
|
232
235
|
: [];
|
|
233
|
-
for (const [index, { scope, handler }] of handlers.entries()) {
|
|
234
|
-
const execution = await this.runHandler(event, handler, index,
|
|
236
|
+
for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
|
|
237
|
+
const execution = await this.runHandler(event, handler, index, matchedInvocation, scope);
|
|
235
238
|
executions.push(execution);
|
|
236
239
|
logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
|
|
237
240
|
hookEvent: event,
|
|
@@ -447,20 +450,33 @@ export async function loadProjectHookConfig(workspaceRoot) {
|
|
|
447
450
|
...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
|
|
448
451
|
};
|
|
449
452
|
}
|
|
450
|
-
function
|
|
453
|
+
function matchHookRule(matcher, invocation) {
|
|
451
454
|
if (!matcher)
|
|
452
|
-
return
|
|
455
|
+
return invocation;
|
|
453
456
|
if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
|
|
454
|
-
return
|
|
457
|
+
return undefined;
|
|
455
458
|
if (matcher.tool) {
|
|
456
459
|
if (typeof invocation.payload?.tool !== "string" || invocation.payload.tool !== matcher.tool) {
|
|
457
|
-
return
|
|
460
|
+
return undefined;
|
|
458
461
|
}
|
|
459
462
|
}
|
|
463
|
+
let matchedInvocation = invocation;
|
|
460
464
|
if (matcher.commandRegex) {
|
|
461
465
|
const command = invocation.payload?.command;
|
|
462
|
-
if (typeof command !== "string"
|
|
463
|
-
return
|
|
466
|
+
if (typeof command !== "string")
|
|
467
|
+
return undefined;
|
|
468
|
+
const commandMatch = new RegExp(matcher.commandRegex).exec(command);
|
|
469
|
+
if (!commandMatch)
|
|
470
|
+
return undefined;
|
|
471
|
+
if (commandMatch[0] !== command) {
|
|
472
|
+
matchedInvocation = {
|
|
473
|
+
...invocation,
|
|
474
|
+
payload: {
|
|
475
|
+
...invocation.payload,
|
|
476
|
+
command: commandMatch[0],
|
|
477
|
+
originalCommand: command,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
464
480
|
}
|
|
465
481
|
}
|
|
466
482
|
if (matcher.pathRegex) {
|
|
@@ -471,15 +487,15 @@ function hookRuleMatches(matcher, invocation) {
|
|
|
471
487
|
const matchesPath = typeof path === "string" && pathPattern.test(path);
|
|
472
488
|
const matchesPaths = Array.isArray(paths) && paths.some((entry) => typeof entry === "string" && new RegExp(pathRegex).test(entry));
|
|
473
489
|
if (!matchesPath && !matchesPaths)
|
|
474
|
-
return
|
|
490
|
+
return undefined;
|
|
475
491
|
}
|
|
476
492
|
if (matcher.provider) {
|
|
477
493
|
if (typeof invocation.payload?.provider !== "string" ||
|
|
478
494
|
invocation.payload.provider !== matcher.provider) {
|
|
479
|
-
return
|
|
495
|
+
return undefined;
|
|
480
496
|
}
|
|
481
497
|
}
|
|
482
|
-
return
|
|
498
|
+
return matchedInvocation;
|
|
483
499
|
}
|
|
484
500
|
function hookEnvironment(baseEnv, event, invocation) {
|
|
485
501
|
return {
|
|
@@ -12,6 +12,7 @@ export const toolNames = {
|
|
|
12
12
|
ls: "ls",
|
|
13
13
|
shell: "bash",
|
|
14
14
|
writeStdin: "write_stdin",
|
|
15
|
+
capability: "capability",
|
|
15
16
|
};
|
|
16
17
|
export function buildShellMutationPolicy() {
|
|
17
18
|
return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
|
|
@@ -43,7 +44,7 @@ function capabilityContractInstructions(config) {
|
|
|
43
44
|
: ` If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, let the user choose whether to resume or close them with ${toolNames.closeWorkspace}; never close them automatically.`;
|
|
44
45
|
const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
|
|
45
46
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
|
|
46
|
-
const capabilityGuides = `
|
|
47
|
+
const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
|
|
47
48
|
const skills = config.skillsEnabled
|
|
48
49
|
? `When a task matches an available skill from ${toolNames.openWorkspace}, read its advertised path before proceeding. Outside normal file roots, ${toolNames.read} permits only advertised entry files and files under already-loaded advertised directories.`
|
|
49
50
|
: "";
|
package/dist/server.js
CHANGED
|
@@ -15,10 +15,12 @@ import express from "express";
|
|
|
15
15
|
import * as z from "zod/v4";
|
|
16
16
|
import { applyPatch } from "./apply-patch.js";
|
|
17
17
|
import { buildCapabilityFingerprint } from "./capabilities.js";
|
|
18
|
+
import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
|
|
18
19
|
import { deletePath, renamePath } from "./file-mutations.js";
|
|
19
20
|
import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
|
|
20
21
|
import { loadConfig } from "./config.js";
|
|
21
22
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
23
|
+
import { checkHookConfiguration } from "./hook-cli.js";
|
|
22
24
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
23
25
|
import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
|
|
24
26
|
import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
@@ -124,6 +126,22 @@ const capabilityGuideOutputSchema = z.object({
|
|
|
124
126
|
whenToRead: z.string(),
|
|
125
127
|
path: z.string(),
|
|
126
128
|
});
|
|
129
|
+
const capabilityCatalogGuideOutputSchema = z.object({
|
|
130
|
+
name: z.string(),
|
|
131
|
+
path: z.string(),
|
|
132
|
+
readBeforeFirstUse: z.boolean(),
|
|
133
|
+
});
|
|
134
|
+
const capabilityCatalogOutputSchema = z.object({
|
|
135
|
+
name: z.string(),
|
|
136
|
+
description: z.string(),
|
|
137
|
+
available: z.boolean(),
|
|
138
|
+
unavailableReason: z.string().optional(),
|
|
139
|
+
guide: capabilityCatalogGuideOutputSchema,
|
|
140
|
+
});
|
|
141
|
+
const capabilityErrorOutputSchema = z.object({
|
|
142
|
+
code: z.string(),
|
|
143
|
+
message: z.string(),
|
|
144
|
+
});
|
|
127
145
|
const workspaceAgentsFileOutputSchema = z.object({
|
|
128
146
|
path: z.string(),
|
|
129
147
|
content: z.string(),
|
|
@@ -317,6 +335,9 @@ ${stylesheets}
|
|
|
317
335
|
</body>
|
|
318
336
|
</html>`;
|
|
319
337
|
}
|
|
338
|
+
function appDomain(config) {
|
|
339
|
+
return new URL(config.publicBaseUrl).origin;
|
|
340
|
+
}
|
|
320
341
|
function appCsp(config) {
|
|
321
342
|
const publicBaseUrl = config.publicBaseUrl.replace(/\/+$/, "");
|
|
322
343
|
return {
|
|
@@ -360,6 +381,7 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
|
|
|
360
381
|
text: workspaceAppHtml(config),
|
|
361
382
|
_meta: {
|
|
362
383
|
ui: {
|
|
384
|
+
domain: appDomain(config),
|
|
363
385
|
csp: appCsp(config),
|
|
364
386
|
},
|
|
365
387
|
},
|
|
@@ -488,6 +510,18 @@ function workspaceHookInvocation(workspace) {
|
|
|
488
510
|
sourceRoot: workspace.sourceRoot,
|
|
489
511
|
};
|
|
490
512
|
}
|
|
513
|
+
function capabilityContextFor(workspace) {
|
|
514
|
+
return {
|
|
515
|
+
workspaceId: workspace.id,
|
|
516
|
+
workspaceRoot: workspace.root,
|
|
517
|
+
guides: workspace.capabilityGuides.map((guide) => ({
|
|
518
|
+
name: guide.name,
|
|
519
|
+
description: guide.description,
|
|
520
|
+
whenToRead: guide.whenToRead,
|
|
521
|
+
path: formatPathForPrompt(guide.filePath),
|
|
522
|
+
})),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
491
525
|
function toolResultIsError(result) {
|
|
492
526
|
return typeof result === "object" && result !== null && result.isError === true;
|
|
493
527
|
}
|
|
@@ -645,6 +679,9 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
645
679
|
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
646
680
|
const toolDescriptions = buildToolDescriptions(config);
|
|
647
681
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
682
|
+
const capabilityRegistry = createCapabilityRegistry({
|
|
683
|
+
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
684
|
+
});
|
|
648
685
|
const server = new McpServer({
|
|
649
686
|
name: "forgerelay",
|
|
650
687
|
title: "ForgeRelay",
|
|
@@ -658,6 +695,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
658
695
|
description: "Interactive card for viewing ForgeRelay file diffs.",
|
|
659
696
|
_meta: {
|
|
660
697
|
ui: {
|
|
698
|
+
domain: appDomain(config),
|
|
661
699
|
csp: appCsp(config),
|
|
662
700
|
},
|
|
663
701
|
},
|
|
@@ -735,6 +773,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
735
773
|
managed: z.boolean(),
|
|
736
774
|
})),
|
|
737
775
|
capabilityFingerprint: capabilityFingerprintOutputSchema,
|
|
776
|
+
capabilityCatalog: z.array(capabilityCatalogOutputSchema),
|
|
738
777
|
capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
|
|
739
778
|
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
|
|
740
779
|
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
|
|
@@ -781,6 +820,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
781
820
|
whenToRead: guide.whenToRead,
|
|
782
821
|
path: formatPathForPrompt(guide.filePath),
|
|
783
822
|
}));
|
|
823
|
+
const capabilityCatalog = capabilityRegistry.catalog(capabilityContextFor(workspace));
|
|
784
824
|
const cardAgentProviders = config.subagents ? localAgentProviders : [];
|
|
785
825
|
const cardAgents = workspace.agentProfiles.map((profile) => {
|
|
786
826
|
const summary = summarizeLocalAgentProfile(profile);
|
|
@@ -842,6 +882,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
842
882
|
visibleSkills.length > 0
|
|
843
883
|
? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
|
|
844
884
|
: undefined,
|
|
885
|
+
capabilityCatalog.length > 0
|
|
886
|
+
? `Optional capabilities: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
|
|
887
|
+
: undefined,
|
|
845
888
|
visibleCapabilityGuides.length > 0
|
|
846
889
|
? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
|
|
847
890
|
: undefined,
|
|
@@ -888,6 +931,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
888
931
|
worktrees: knownWorktrees,
|
|
889
932
|
staleWorkspaces,
|
|
890
933
|
capabilityFingerprint,
|
|
934
|
+
capabilityCatalog,
|
|
891
935
|
agentsFiles: cardAgentsFiles,
|
|
892
936
|
availableAgentsFiles: cardAvailableAgentsFiles,
|
|
893
937
|
skills: cardSkills,
|
|
@@ -899,6 +943,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
899
943
|
agentsFiles: cardAgentsFiles.length,
|
|
900
944
|
availableAgentsFiles: cardAvailableAgentsFiles.length,
|
|
901
945
|
skills: cardSkills.length,
|
|
946
|
+
capabilities: capabilityCatalog.length,
|
|
902
947
|
agentProviders: cardAgentProviders.length,
|
|
903
948
|
agents: cardAgents.length,
|
|
904
949
|
},
|
|
@@ -913,6 +958,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
913
958
|
worktrees: knownWorktrees,
|
|
914
959
|
staleWorkspaces,
|
|
915
960
|
capabilityFingerprint,
|
|
961
|
+
capabilityCatalog,
|
|
916
962
|
...(includeBootstrapContext
|
|
917
963
|
? {
|
|
918
964
|
capabilityGuides: visibleCapabilityGuides,
|
|
@@ -928,6 +974,107 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
928
974
|
},
|
|
929
975
|
}, hookReports));
|
|
930
976
|
});
|
|
977
|
+
registerAppTool(server, toolNames.capability, {
|
|
978
|
+
title: "Use optional capability",
|
|
979
|
+
description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
|
|
980
|
+
inputSchema: {
|
|
981
|
+
workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
|
|
982
|
+
name: z
|
|
983
|
+
.string()
|
|
984
|
+
.regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/)
|
|
985
|
+
.describe("Stable dotted capability name advertised by open_workspace."),
|
|
986
|
+
action: z.enum(["describe", "run"]),
|
|
987
|
+
arguments: z
|
|
988
|
+
.record(z.string(), z.unknown())
|
|
989
|
+
.optional()
|
|
990
|
+
.describe("Capability-specific arguments. Omit for describe and for capabilities with no arguments."),
|
|
991
|
+
},
|
|
992
|
+
outputSchema: {
|
|
993
|
+
name: z.string(),
|
|
994
|
+
action: z.enum(["describe", "run"]),
|
|
995
|
+
capability: z.unknown().optional(),
|
|
996
|
+
result: z.unknown().optional(),
|
|
997
|
+
error: capabilityErrorOutputSchema.optional(),
|
|
998
|
+
},
|
|
999
|
+
_meta: {},
|
|
1000
|
+
annotations: {
|
|
1001
|
+
readOnlyHint: false,
|
|
1002
|
+
destructiveHint: false,
|
|
1003
|
+
idempotentHint: false,
|
|
1004
|
+
openWorldHint: false,
|
|
1005
|
+
},
|
|
1006
|
+
}, async ({ workspaceId, name, action, arguments: capabilityArguments }) => {
|
|
1007
|
+
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1008
|
+
return runToolWithHooks(hooks, {
|
|
1009
|
+
tool: toolNames.capability,
|
|
1010
|
+
invocation: workspaceHookInvocation(workspace),
|
|
1011
|
+
payload: { name, action },
|
|
1012
|
+
isFailure: toolResultIsError,
|
|
1013
|
+
operation: async () => {
|
|
1014
|
+
const startedAt = performance.now();
|
|
1015
|
+
try {
|
|
1016
|
+
if (action === "describe") {
|
|
1017
|
+
const capability = capabilityRegistry.describe(name, capabilityContextFor(workspace));
|
|
1018
|
+
const result = {
|
|
1019
|
+
content: [textBlock([
|
|
1020
|
+
`${capability.name}: ${capability.description}`,
|
|
1021
|
+
`Available: ${capability.available}`,
|
|
1022
|
+
`Guide: ${capability.guide.path}`,
|
|
1023
|
+
capability.guide.readBeforeFirstUse
|
|
1024
|
+
? "Read the guide before first use when this contract is unfamiliar."
|
|
1025
|
+
: undefined,
|
|
1026
|
+
].filter(Boolean).join("\n"))],
|
|
1027
|
+
structuredContent: { name, action, capability },
|
|
1028
|
+
};
|
|
1029
|
+
logToolCall(config, {
|
|
1030
|
+
tool: toolNames.capability,
|
|
1031
|
+
...workspaceLogContext(workspace),
|
|
1032
|
+
capability: name,
|
|
1033
|
+
action,
|
|
1034
|
+
success: true,
|
|
1035
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1036
|
+
});
|
|
1037
|
+
return result;
|
|
1038
|
+
}
|
|
1039
|
+
const capabilityResult = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace));
|
|
1040
|
+
const result = {
|
|
1041
|
+
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(capabilityResult, null, 2)}`)],
|
|
1042
|
+
structuredContent: { name, action, result: capabilityResult },
|
|
1043
|
+
};
|
|
1044
|
+
logToolCall(config, {
|
|
1045
|
+
tool: toolNames.capability,
|
|
1046
|
+
...workspaceLogContext(workspace),
|
|
1047
|
+
capability: name,
|
|
1048
|
+
action,
|
|
1049
|
+
success: true,
|
|
1050
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1051
|
+
});
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
const capabilityError = error instanceof CapabilityError
|
|
1056
|
+
? error
|
|
1057
|
+
: new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
|
|
1058
|
+
const result = {
|
|
1059
|
+
content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
|
|
1060
|
+
structuredContent: {
|
|
1061
|
+
name,
|
|
1062
|
+
action,
|
|
1063
|
+
error: { code: capabilityError.code, message: capabilityError.message },
|
|
1064
|
+
},
|
|
1065
|
+
isError: true,
|
|
1066
|
+
};
|
|
1067
|
+
logFailedToolResponse(config, {
|
|
1068
|
+
tool: toolNames.capability,
|
|
1069
|
+
...workspaceLogContext(workspace),
|
|
1070
|
+
capability: name,
|
|
1071
|
+
action,
|
|
1072
|
+
}, result.content, startedAt);
|
|
1073
|
+
return result;
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
931
1078
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
932
1079
|
title: "Close logical workspace",
|
|
933
1080
|
description: "Release one logical workspaceId after the user chooses cleanup. This does not delete checkout files. Use close_worktree to finalize and remove a managed worktree. Running or unconsumed processes prevent closure.",
|
package/docs/configuration.md
CHANGED
|
@@ -211,7 +211,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
|
|
|
211
211
|
"event": "BeforeTool",
|
|
212
212
|
"matcher": {
|
|
213
213
|
"tool": "bash",
|
|
214
|
-
"commandRegex": "
|
|
214
|
+
"commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
|
|
215
215
|
},
|
|
216
216
|
"command": "npm run release:verify",
|
|
217
217
|
"timeoutSeconds": 300,
|
|
@@ -255,7 +255,7 @@ forgerelay hooks check --project /path/to/project
|
|
|
255
255
|
| 字段 | 匹配方式 |
|
|
256
256
|
| --- | --- |
|
|
257
257
|
| `tool` | 精确匹配 MCP tool 名称。 |
|
|
258
|
-
| `commandRegex` | 对 tool payload 中的 `command` 做 JavaScript
|
|
258
|
+
| `commandRegex` | 对 tool payload 中的 `command` 做 JavaScript 正则匹配;命中后 Hook 收到的 `payload.command` 是实际匹配片段,完整原命令在片段不等于整串命令时保留为 `payload.originalCommand`。 |
|
|
259
259
|
| `pathRegex` | 对 payload 中的 `path` 或 `paths` 做正则匹配。 |
|
|
260
260
|
| `provider` | 精确匹配 subagent provider。 |
|
|
261
261
|
| `workspaceMode` | `checkout` 或 `worktree`。 |
|
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
|
@@ -104,29 +104,75 @@ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生
|
|
|
104
104
|
|
|
105
105
|
## 0.3 — MCP loading 与渐进式能力披露
|
|
106
106
|
|
|
107
|
-
0.3
|
|
107
|
+
0.3 的目标是把 ForgeRelay 的 MCP interface 做成一个深而稳定的模型接口:普通编码 primitive 始终直接可见,低频知识与低频 action 都按需披露,新增 ForgeRelay 能力不再线性扩大 Host 首次加载的 `tools/list` 与 instructions。
|
|
108
108
|
|
|
109
|
-
- `tools/list`
|
|
110
|
-
- `open_workspace` 返回紧凑的 server/capability 摘要与版本指纹,帮助 Agent 发现能力,并识别 Host 持有旧 tool schema snapshot 的情况;
|
|
111
|
-
- ForgeRelay-owned capability guide 提供低频能力的完整说明,由 Agent 在任务相关时显式 `read`,而不是首次连接时自动注入。
|
|
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 自身、与版本绑定的产品能力。
|
|
112
110
|
|
|
113
|
-
|
|
111
|
+
0.3 后续阶段采用 ADR-0002 的接口形状。Canonical Core tool surface 最终固定为:
|
|
114
112
|
|
|
115
|
-
|
|
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 在迁移窗口内只作为兼容入口,不再作为长期接口设计。
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
- 常用文件读写改、`rename` 同时承担 move/rename、删除的核心语义;
|
|
119
|
-
- shell 以本地用户权限执行且不是 OS sandbox;
|
|
120
|
-
- `processId` / `write_stdin` 的基本长进程语义;
|
|
121
|
-
- Hook 阻断结果必须对 Agent 可见;
|
|
122
|
-
- 关键 mutation/safety invariant;
|
|
123
|
-
- `close_workspace` 与 `close_worktree` 的区别。
|
|
155
|
+
### 0.3.4 — Workspace 与 Process 生命周期收敛
|
|
124
156
|
|
|
125
|
-
|
|
157
|
+
移除两个泄漏内部实现的 public lifecycle tool:
|
|
126
158
|
|
|
127
|
-
|
|
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。
|
|
128
163
|
|
|
129
|
-
0.3
|
|
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。
|
|
130
176
|
|
|
131
177
|
## 0.4 — LSP code intelligence v1
|
|
132
178
|
|
|
@@ -151,20 +197,7 @@ Initial operations:
|
|
|
151
197
|
- workspace symbols;
|
|
152
198
|
- hover/type information.
|
|
153
199
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
```text
|
|
157
|
-
code_intelligence({
|
|
158
|
-
workspaceId,
|
|
159
|
-
operation,
|
|
160
|
-
path,
|
|
161
|
-
line,
|
|
162
|
-
column,
|
|
163
|
-
query
|
|
164
|
-
})
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
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.
|
|
168
201
|
|
|
169
202
|
Candidate servers include `typescript-language-server`/tsserver, Pyright,
|
|
170
203
|
`rust-analyzer`, `gopls`, and `clangd`, but ForgeRelay should treat server
|
|
@@ -176,22 +209,7 @@ ForgeRelay already owns provider adapters and resumable local agent sessions.
|
|
|
176
209
|
The next step is to remove the current `bash -> forgerelay agents ...` indirection
|
|
177
210
|
for MCP hosts.
|
|
178
211
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
```text
|
|
182
|
-
subagent({
|
|
183
|
-
action: "run" | "list" | "show" | "cancel",
|
|
184
|
-
workspaceId,
|
|
185
|
-
profile,
|
|
186
|
-
provider,
|
|
187
|
-
prompt,
|
|
188
|
-
agentId
|
|
189
|
-
})
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
The parent agent chooses an available provider/profile such as Codex or Claude.
|
|
193
|
-
ForgeRelay launches, tracks, resumes, and cancels the provider-backed worker when
|
|
194
|
-
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.
|
|
195
213
|
|
|
196
214
|
This is intentionally provider-backed delegation, not an attempt to emulate a
|
|
197
215
|
host-native subagent implementation.
|
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,
|
|
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
|
|
package/docs/versioning.md
CHANGED
|
@@ -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.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"debug:accept": "node scripts/debug/accept.mjs",
|
|
43
43
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
44
44
|
"start": "node dist/cli.js serve",
|
|
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
|
+
"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/capability-registry.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",
|
|
46
46
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
47
|
"release:check": "node scripts/release-version.mjs check",
|
|
48
48
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
package/scripts/debug/accept.mjs
CHANGED
|
@@ -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,
|
|
@@ -117,7 +129,7 @@ try {
|
|
|
117
129
|
params: {},
|
|
118
130
|
}).message.result.tools;
|
|
119
131
|
const toolNames = tools.map((tool) => tool.name);
|
|
120
|
-
for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin"]) {
|
|
132
|
+
for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin", "capability"]) {
|
|
121
133
|
assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
|
|
122
134
|
}
|
|
123
135
|
const bashTool = tools.find((tool) => tool.name === "bash");
|
|
@@ -144,6 +156,7 @@ try {
|
|
|
144
156
|
assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
|
|
145
157
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
|
|
146
158
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityFingerprint);
|
|
159
|
+
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityCatalog);
|
|
147
160
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
|
|
148
161
|
const templateUri = bashTool?._meta?.ui?.resourceUri;
|
|
149
162
|
assert.match(
|
|
@@ -161,7 +174,9 @@ try {
|
|
|
161
174
|
method: "resources/list",
|
|
162
175
|
params: {},
|
|
163
176
|
}).message.result.resources;
|
|
164
|
-
|
|
177
|
+
const currentResource = resources.find((resource) => resource.uri === templateUri);
|
|
178
|
+
assert.ok(currentResource);
|
|
179
|
+
assert.equal(currentResource._meta?.ui?.domain, debugBaseUrl);
|
|
165
180
|
assert.ok(resources.some((resource) => resource.uri === "ui://forgerelay/workspace-app.html"));
|
|
166
181
|
|
|
167
182
|
const resourceTemplates = mcpRequest(oauth.accessToken, sessionId, {
|
|
@@ -185,6 +200,7 @@ try {
|
|
|
185
200
|
assert.equal(template.uri, templateUri);
|
|
186
201
|
assert.equal(template.mimeType, "text/html;profile=mcp-app");
|
|
187
202
|
assert.match(template.text ?? "", /<script type="module" crossorigin src="[^"]+\/mcp-app-assets\//);
|
|
203
|
+
assert.equal(template._meta?.ui?.domain, debugBaseUrl);
|
|
188
204
|
assert.ok(template._meta?.ui?.csp?.resourceDomains?.includes(debugBaseUrl));
|
|
189
205
|
const scriptUrl = template.text?.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
|
|
190
206
|
assert.ok(scriptUrl);
|
|
@@ -225,6 +241,34 @@ try {
|
|
|
225
241
|
"ui.mcp-app",
|
|
226
242
|
],
|
|
227
243
|
});
|
|
244
|
+
const capabilityCatalog = opened.structuredContent.capabilityCatalog;
|
|
245
|
+
assert.deepEqual(capabilityCatalog.map((entry) => entry.name), ["hooks.check"]);
|
|
246
|
+
assert.equal(capabilityCatalog[0].available, true);
|
|
247
|
+
assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
|
|
248
|
+
const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
|
|
249
|
+
workspaceId,
|
|
250
|
+
name: "hooks.check",
|
|
251
|
+
action: "run",
|
|
252
|
+
arguments: {},
|
|
253
|
+
});
|
|
254
|
+
assert.equal(directCapability.isError, undefined);
|
|
255
|
+
assert.equal(directCapability.structuredContent.result.ok, true);
|
|
256
|
+
const describedCapability = callTool(oauth.accessToken, sessionId, 80, "capability", {
|
|
257
|
+
workspaceId,
|
|
258
|
+
name: "hooks.check",
|
|
259
|
+
action: "describe",
|
|
260
|
+
});
|
|
261
|
+
assert.equal(describedCapability.isError, undefined);
|
|
262
|
+
assert.equal(describedCapability.structuredContent.capability.guide.name, "lifecycle-hooks");
|
|
263
|
+
assert.equal(describedCapability.structuredContent.capability.inputSchema.type, "object");
|
|
264
|
+
const unknownCapability = callTool(oauth.accessToken, sessionId, 81, "capability", {
|
|
265
|
+
workspaceId,
|
|
266
|
+
name: "unknown.capability",
|
|
267
|
+
action: "run",
|
|
268
|
+
arguments: {},
|
|
269
|
+
});
|
|
270
|
+
assert.equal(unknownCapability.isError, true);
|
|
271
|
+
assert.equal(unknownCapability.structuredContent.error.code, "unknown_capability");
|
|
228
272
|
const capabilityGuides = opened.structuredContent.capabilityGuides;
|
|
229
273
|
assert.deepEqual(capabilityGuides.map((guide) => guide.name), [
|
|
230
274
|
"lifecycle-hooks",
|
|
@@ -238,7 +282,7 @@ try {
|
|
|
238
282
|
});
|
|
239
283
|
assert.match(hooksGuide.structuredContent.result, /BeforeTool/);
|
|
240
284
|
assert.match(hooksGuide.structuredContent.result, /BeforeWorktreeClose/);
|
|
241
|
-
pass("open_workspace", `${workspaceId} ->
|
|
285
|
+
pass("open_workspace", `${workspaceId} -> ${capabilityCatalog.length} capabilities + ${capabilityGuides.length} capability guides`);
|
|
242
286
|
|
|
243
287
|
const written = callTool(oauth.accessToken, sessionId, 4, "write", {
|
|
244
288
|
workspaceId,
|
|
@@ -655,8 +699,9 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
655
699
|
[
|
|
656
700
|
'import { writeFileSync } from "node:fs";',
|
|
657
701
|
'const payload = process.env.FORGERELAY_HOOK_PAYLOAD ?? "{}";',
|
|
702
|
+
'const parsed = JSON.parse(payload);',
|
|
658
703
|
'writeFileSync("release-ci-ran.txt", payload);',
|
|
659
|
-
'if (
|
|
704
|
+
'if (parsed.command === "git push origin v0.2.1") process.exit(17);',
|
|
660
705
|
"",
|
|
661
706
|
].join("\n"),
|
|
662
707
|
);
|
|
@@ -664,7 +709,7 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
664
709
|
join(releaseProject, ".forgerelay", "hooks", "release-tag-local-ci.json"),
|
|
665
710
|
JSON.stringify({
|
|
666
711
|
event: "BeforeTool",
|
|
667
|
-
matcher: { tool: "bash", commandRegex: "
|
|
712
|
+
matcher: { tool: "bash", commandRegex: "git push origin v0\\.2\\.[01]" },
|
|
668
713
|
command: "node .forgerelay/release-check.mjs",
|
|
669
714
|
timeoutSeconds: 30,
|
|
670
715
|
report: true,
|
|
@@ -682,11 +727,17 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
682
727
|
|
|
683
728
|
const pushed = callTool(accessToken, sessionId, 12, "bash", {
|
|
684
729
|
workspaceId: releaseWorkspaceId,
|
|
685
|
-
command: "git push origin v0.2.0",
|
|
730
|
+
command: "git status --short && git push origin v0.2.0 && echo release-pushed",
|
|
686
731
|
});
|
|
687
732
|
assert.equal(pushed.isError, undefined);
|
|
688
733
|
assert.match(toolText(pushed), /release-tag-local-ci \(BeforeTool, project\) passed/);
|
|
689
734
|
assert.ok(existsSync(join(releaseProject, "release-ci-ran.txt")));
|
|
735
|
+
assert.deepEqual(JSON.parse(readFileSync(join(releaseProject, "release-ci-ran.txt"), "utf8")), {
|
|
736
|
+
tool: "bash",
|
|
737
|
+
command: "git push origin v0.2.0",
|
|
738
|
+
workingDirectory: ".",
|
|
739
|
+
originalCommand: "git status --short && git push origin v0.2.0 && echo release-pushed",
|
|
740
|
+
});
|
|
690
741
|
assert.equal(
|
|
691
742
|
gitOutput(releaseRemote, ["rev-parse", "refs/tags/v0.2.0"], { gitDir: true }),
|
|
692
743
|
gitOutput(releaseProject, ["rev-parse", "v0.2.0"]),
|
|
@@ -694,7 +745,7 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
694
745
|
|
|
695
746
|
const blocked = callTool(accessToken, sessionId, 13, "bash", {
|
|
696
747
|
workspaceId: releaseWorkspaceId,
|
|
697
|
-
command: "git push origin v0.2.1",
|
|
748
|
+
command: "git status --short && git push origin v0.2.1 && echo should-not-run",
|
|
698
749
|
});
|
|
699
750
|
assert.equal(blocked.isError, true);
|
|
700
751
|
assert.match(toolText(blocked), /release-tag-local-ci.*failed/);
|
|
@@ -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
|
|
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)}`);
|