@akira-tl/forgerelay 0.3.1 → 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 +11 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +3 -1
- package/dist/capability-registry.js +118 -0
- package/dist/hook-cli.js +12 -0
- package/dist/mcp/server-instructions.js +2 -1
- package/dist/server.js +142 -0
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +31 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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
|
+
|
|
7
18
|
## [0.3.1] - 2026-08-10
|
|
8
19
|
|
|
9
20
|
### Changed
|
|
@@ -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/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)) {
|
|
@@ -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(),
|
|
@@ -492,6 +510,18 @@ function workspaceHookInvocation(workspace) {
|
|
|
492
510
|
sourceRoot: workspace.sourceRoot,
|
|
493
511
|
};
|
|
494
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
|
+
}
|
|
495
525
|
function toolResultIsError(result) {
|
|
496
526
|
return typeof result === "object" && result !== null && result.isError === true;
|
|
497
527
|
}
|
|
@@ -649,6 +679,9 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
649
679
|
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
650
680
|
const toolDescriptions = buildToolDescriptions(config);
|
|
651
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
|
+
});
|
|
652
685
|
const server = new McpServer({
|
|
653
686
|
name: "forgerelay",
|
|
654
687
|
title: "ForgeRelay",
|
|
@@ -740,6 +773,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
740
773
|
managed: z.boolean(),
|
|
741
774
|
})),
|
|
742
775
|
capabilityFingerprint: capabilityFingerprintOutputSchema,
|
|
776
|
+
capabilityCatalog: z.array(capabilityCatalogOutputSchema),
|
|
743
777
|
capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
|
|
744
778
|
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
|
|
745
779
|
availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
|
|
@@ -786,6 +820,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
786
820
|
whenToRead: guide.whenToRead,
|
|
787
821
|
path: formatPathForPrompt(guide.filePath),
|
|
788
822
|
}));
|
|
823
|
+
const capabilityCatalog = capabilityRegistry.catalog(capabilityContextFor(workspace));
|
|
789
824
|
const cardAgentProviders = config.subagents ? localAgentProviders : [];
|
|
790
825
|
const cardAgents = workspace.agentProfiles.map((profile) => {
|
|
791
826
|
const summary = summarizeLocalAgentProfile(profile);
|
|
@@ -847,6 +882,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
847
882
|
visibleSkills.length > 0
|
|
848
883
|
? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
|
|
849
884
|
: undefined,
|
|
885
|
+
capabilityCatalog.length > 0
|
|
886
|
+
? `Optional capabilities: ${capabilityCatalog.map((entry) => entry.name).join(", ")}`
|
|
887
|
+
: undefined,
|
|
850
888
|
visibleCapabilityGuides.length > 0
|
|
851
889
|
? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
|
|
852
890
|
: undefined,
|
|
@@ -893,6 +931,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
893
931
|
worktrees: knownWorktrees,
|
|
894
932
|
staleWorkspaces,
|
|
895
933
|
capabilityFingerprint,
|
|
934
|
+
capabilityCatalog,
|
|
896
935
|
agentsFiles: cardAgentsFiles,
|
|
897
936
|
availableAgentsFiles: cardAvailableAgentsFiles,
|
|
898
937
|
skills: cardSkills,
|
|
@@ -904,6 +943,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
904
943
|
agentsFiles: cardAgentsFiles.length,
|
|
905
944
|
availableAgentsFiles: cardAvailableAgentsFiles.length,
|
|
906
945
|
skills: cardSkills.length,
|
|
946
|
+
capabilities: capabilityCatalog.length,
|
|
907
947
|
agentProviders: cardAgentProviders.length,
|
|
908
948
|
agents: cardAgents.length,
|
|
909
949
|
},
|
|
@@ -918,6 +958,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
918
958
|
worktrees: knownWorktrees,
|
|
919
959
|
staleWorkspaces,
|
|
920
960
|
capabilityFingerprint,
|
|
961
|
+
capabilityCatalog,
|
|
921
962
|
...(includeBootstrapContext
|
|
922
963
|
? {
|
|
923
964
|
capabilityGuides: visibleCapabilityGuides,
|
|
@@ -933,6 +974,107 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
933
974
|
},
|
|
934
975
|
}, hookReports));
|
|
935
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
|
+
});
|
|
936
1078
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
937
1079
|
title: "Close logical workspace",
|
|
938
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/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
|
@@ -129,7 +129,7 @@ try {
|
|
|
129
129
|
params: {},
|
|
130
130
|
}).message.result.tools;
|
|
131
131
|
const toolNames = tools.map((tool) => tool.name);
|
|
132
|
-
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"]) {
|
|
133
133
|
assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
|
|
134
134
|
}
|
|
135
135
|
const bashTool = tools.find((tool) => tool.name === "bash");
|
|
@@ -156,6 +156,7 @@ try {
|
|
|
156
156
|
assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
|
|
157
157
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
|
|
158
158
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityFingerprint);
|
|
159
|
+
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityCatalog);
|
|
159
160
|
assert.ok(openWorkspaceTool?.outputSchema?.properties?.capabilityGuides);
|
|
160
161
|
const templateUri = bashTool?._meta?.ui?.resourceUri;
|
|
161
162
|
assert.match(
|
|
@@ -240,6 +241,34 @@ try {
|
|
|
240
241
|
"ui.mcp-app",
|
|
241
242
|
],
|
|
242
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");
|
|
243
272
|
const capabilityGuides = opened.structuredContent.capabilityGuides;
|
|
244
273
|
assert.deepEqual(capabilityGuides.map((guide) => guide.name), [
|
|
245
274
|
"lifecycle-hooks",
|
|
@@ -253,7 +282,7 @@ try {
|
|
|
253
282
|
});
|
|
254
283
|
assert.match(hooksGuide.structuredContent.result, /BeforeTool/);
|
|
255
284
|
assert.match(hooksGuide.structuredContent.result, /BeforeWorktreeClose/);
|
|
256
|
-
pass("open_workspace", `${workspaceId} ->
|
|
285
|
+
pass("open_workspace", `${workspaceId} -> ${capabilityCatalog.length} capabilities + ${capabilityGuides.length} capability guides`);
|
|
257
286
|
|
|
258
287
|
const written = callTool(oauth.accessToken, sessionId, 4, "write", {
|
|
259
288
|
workspaceId,
|