@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 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
- 使用 `forgerelay hooks list` 查看已发现规则,使用 `forgerelay hooks check` 做只读校验。排查 Hook 时优先确认配置来源、event/matcher 是否命中、handler 的实际退出状态,以及 tool result 中的 Hook report。
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 = `When ${toolNames.openWorkspace} returns capability guides, use ${toolNames.read} to load only a task-relevant guide; do not preload all guides.`;
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.1",
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",
@@ -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} -> fingerprint + ${capabilityGuides.length} capability guides`);
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,