@akira-tl/forgerelay 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +14 -0
  3. package/dist/activity/runtime/mcp-query-tools.js +15 -5
  4. package/dist/cli/config/inspect.js +331 -0
  5. package/dist/cli/config/migrate.js +351 -0
  6. package/dist/cli/init/setup-config.js +41 -0
  7. package/dist/cli/init.js +128 -130
  8. package/dist/cli/maintenance.js +15 -5
  9. package/dist/cli/mcp/external-mcp.js +14 -10
  10. package/dist/cli/mcp/status.js +11 -6
  11. package/dist/cli.js +40 -7
  12. package/dist/lsp/language-server-config.js +66 -144
  13. package/dist/lsp/runtime/manager.js +35 -4
  14. package/dist/mcp/hooks/config.js +141 -0
  15. package/dist/mcp/hooks/external-mcp-transform.js +22 -13
  16. package/dist/mcp/hooks/hook-cli.js +62 -52
  17. package/dist/mcp/hooks/hooks.js +111 -85
  18. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +8 -5
  19. package/dist/mcp/operations/external-mcp/external-mcp.js +9 -2
  20. package/dist/mcp/server/core/capability-support.js +7 -0
  21. package/dist/runtime/config/config.js +142 -64
  22. package/dist/runtime/config/definition/catalog.js +12 -0
  23. package/dist/runtime/config/definition/definition.js +115 -0
  24. package/dist/runtime/config/definition/external-mcp.js +58 -0
  25. package/dist/runtime/config/definition/general-config.js +258 -0
  26. package/dist/runtime/config/definition/language-servers.js +116 -0
  27. package/dist/runtime/config/definition/schema.js +113 -0
  28. package/dist/runtime/config/definition/types.js +2 -0
  29. package/dist/runtime/config/external-mcp-auth-store.js +105 -29
  30. package/dist/runtime/config/external-mcp-config.js +39 -0
  31. package/dist/runtime/config/external-mcp-registry.js +284 -106
  32. package/dist/runtime/config/inspection/live.js +77 -0
  33. package/dist/runtime/config/resolution/general.js +93 -0
  34. package/dist/runtime/config/resolution/hooks.js +315 -0
  35. package/dist/runtime/config/resolution/language-servers.js +237 -0
  36. package/dist/runtime/config/resolution/project-sources.js +159 -0
  37. package/dist/runtime/config/resolution/resolver.js +485 -0
  38. package/dist/runtime/config/resolution/types.js +1 -0
  39. package/dist/runtime/config/runtime/config-runtime.js +74 -0
  40. package/dist/runtime/config/runtime/source-refresh.js +263 -0
  41. package/dist/runtime/config/user-config.js +38 -31
  42. package/dist/runtime/security/project-execution-trust.js +34 -0
  43. package/dist/runtime/testing/server-fixture.js +9 -8
  44. package/dist/server.js +10 -6
  45. package/dist/subagents/profiles.js +336 -53
  46. package/dist/subagents/sessions/execution.js +6 -35
  47. package/dist/subagents/sessions/manager.js +14 -2
  48. package/dist/ui/.vite/manifest.json +275 -275
  49. package/dist/ui/activity-panel-app.html +1 -1
  50. package/dist/ui/assets/{activity-panel-app-Bw7ZFX2g.js → activity-panel-app-BeDiwd7D.js} +3 -3
  51. package/dist/ui/assets/{angular-html-DeUNP12X.js → angular-html-DuoCE7nn.js} +1 -1
  52. package/dist/ui/assets/{angular-ts-B3v8FKi_.js → angular-ts-D5tGk0hL.js} +1 -1
  53. package/dist/ui/assets/{apl-Bs3iQm4P.js → apl-lciOTPbw.js} +1 -1
  54. package/dist/ui/assets/{astro-D6GHecBY.js → astro-vJbqfNnJ.js} +1 -1
  55. package/dist/ui/assets/{blade-BMNbVOHv.js → blade-C7655Q4K.js} +1 -1
  56. package/dist/ui/assets/{c-BquuA6bv.js → c-DdHp1Jrw.js} +1 -1
  57. package/dist/ui/assets/{cobol-BuZ6kfps.js → cobol-DgvzQxZP.js} +1 -1
  58. package/dist/ui/assets/{coffee-DgC5uBzL.js → coffee-BIjVUOUd.js} +1 -1
  59. package/dist/ui/assets/{cpp-BdvWqCaB.js → cpp-CU8tGzvI.js} +1 -1
  60. package/dist/ui/assets/{crystal-0Q5qBvNI.js → crystal-CUQAwsg9.js} +1 -1
  61. package/dist/ui/assets/{css-Be_T8idh.js → css-DtMTqD3F.js} +1 -1
  62. package/dist/ui/assets/{edge-DaGLdgh_.js → edge-DoCX9RnL.js} +1 -1
  63. package/dist/ui/assets/{elixir-CMgiFIzB.js → elixir-DJxV9_U6.js} +1 -1
  64. package/dist/ui/assets/{elm-B-ufMlMC.js → elm-DIgWqK4r.js} +1 -1
  65. package/dist/ui/assets/{erb-CaGiOZM-.js → erb-BHxP0c_e.js} +1 -1
  66. package/dist/ui/assets/{git-rebase-CpwUWCzK.js → git-rebase-HbI_Vo8d.js} +1 -1
  67. package/dist/ui/assets/{glimmer-js-BRxccvnE.js → glimmer-js-CthY4Ty_.js} +1 -1
  68. package/dist/ui/assets/{glimmer-ts-CgQbPzlT.js → glimmer-ts-B2-_A1Xz.js} +1 -1
  69. package/dist/ui/assets/{glsl-D7JmHFr0.js → glsl-D9V9gq98.js} +1 -1
  70. package/dist/ui/assets/{graphql-C8n0eUBK.js → graphql-CddeAaS3.js} +1 -1
  71. package/dist/ui/assets/{hack-lVN7KVQv.js → hack-D_loGkOT.js} +1 -1
  72. package/dist/ui/assets/{haml-h18lG0pS.js → haml-BoWDczd0.js} +1 -1
  73. package/dist/ui/assets/{handlebars-lRFjRPfj.js → handlebars-ZoSOvw9y.js} +1 -1
  74. package/dist/ui/assets/{heavy-payload-Cqx9MOkH.js → heavy-payload-DO34LwoZ.js} +3 -3
  75. package/dist/ui/assets/{html-DtILqTE1.js → html-CE-obXVo.js} +1 -1
  76. package/dist/ui/assets/{html-derivative-CSTSmPup.js → html-derivative-Cd-smAbW.js} +1 -1
  77. package/dist/ui/assets/{http-DNI8fsbY.js → http-CZrkbrSl.js} +1 -1
  78. package/dist/ui/assets/{hurl-DgRi60JG.js → hurl-CxTuxBt1.js} +1 -1
  79. package/dist/ui/assets/{java-CW9pZ5bh.js → java-BshLjCat.js} +1 -1
  80. package/dist/ui/assets/{javascript-Cvg2UrWL.js → javascript-D2pxQTiX.js} +1 -1
  81. package/dist/ui/assets/{jinja-CZw_S1FB.js → jinja-kbfPP7-F.js} +1 -1
  82. package/dist/ui/assets/{jison-C56u_FZv.js → jison-C2mt9l1M.js} +1 -1
  83. package/dist/ui/assets/{json-DW2UTv1q.js → json-DU_rg_AU.js} +1 -1
  84. package/dist/ui/assets/{jsx-Zj5gobdD.js → jsx-D_8aQMlx.js} +1 -1
  85. package/dist/ui/assets/{julia-Cvcrre-L.js → julia-BmefTaVV.js} +1 -1
  86. package/dist/ui/assets/{just-8CZ2u6Tj.js → just-DXV8vk1S.js} +1 -1
  87. package/dist/ui/assets/{latex-D289AXWG.js → latex-Cjm2MvGW.js} +1 -1
  88. package/dist/ui/assets/{liquid-CFEX3SkZ.js → liquid-DyQDlo0u.js} +1 -1
  89. package/dist/ui/assets/{lua-BKpJ0ybO.js → lua-B2A1vzzy.js} +1 -1
  90. package/dist/ui/assets/{marko-D9maZ0a6.js → marko-B9vOkXOU.js} +1 -1
  91. package/dist/ui/assets/{mdc-rM9l0qWR.js → mdc-D42m6spU.js} +1 -1
  92. package/dist/ui/assets/{nginx-C9w6p2C9.js → nginx-TpfVxSUY.js} +1 -1
  93. package/dist/ui/assets/{nim-BRePHk0J.js → nim-CJr5T1FK.js} +1 -1
  94. package/dist/ui/assets/{perl-CskTmiPQ.js → perl-cJZcsdm9.js} +1 -1
  95. package/dist/ui/assets/{php-DCU_FvNI.js → php-CSZL5KRT.js} +1 -1
  96. package/dist/ui/assets/{pug-XLaVxTvv.js → pug-B1eMKzuA.js} +1 -1
  97. package/dist/ui/assets/{qml-CaONKSl-.js → qml-Dl2_7zc9.js} +1 -1
  98. package/dist/ui/assets/{r-BCVR0ZE3.js → r-F0aaO9gG.js} +1 -1
  99. package/dist/ui/assets/{razor-DDnGXxnU.js → razor-BPnkSMCw.js} +1 -1
  100. package/dist/ui/assets/{regexp-DWGTYYVx.js → regexp-BnGHMFbv.js} +1 -1
  101. package/dist/ui/assets/{rst-Dz3GWvQm.js → rst-B3CO7WYO.js} +1 -1
  102. package/dist/ui/assets/{ruby-DC3jGtMY.js → ruby-B6VMzT1T.js} +1 -1
  103. package/dist/ui/assets/{sas-7mNpvsjZ.js → sas-Df2lHVuU.js} +1 -1
  104. package/dist/ui/assets/{scss-uBsoCJVy.js → scss-4XxI6sgu.js} +1 -1
  105. package/dist/ui/assets/{shellscript-BCN9N_Jf.js → shellscript-DYASCN4r.js} +1 -1
  106. package/dist/ui/assets/{shellsession-Hsw46VXV.js → shellsession-Br6AGJO_.js} +1 -1
  107. package/dist/ui/assets/{soy-D6KV69HU.js → soy-p3ZNq1We.js} +1 -1
  108. package/dist/ui/assets/{sql-CVzVQkJh.js → sql-CAdv2OXL.js} +1 -1
  109. package/dist/ui/assets/{stata-BwUjXNhI.js → stata-Dx7yUbG_.js} +1 -1
  110. package/dist/ui/assets/{surrealql-CgloWqRa.js → surrealql-6YsE8HQa.js} +1 -1
  111. package/dist/ui/assets/{svelte-DcC3n6IC.js → svelte-YgzedwpD.js} +1 -1
  112. package/dist/ui/assets/{templ-DOFfnWTJ.js → templ-Bahqx3uD.js} +1 -1
  113. package/dist/ui/assets/{tex-ln89VPaX.js → tex-o6fTQjkH.js} +1 -1
  114. package/dist/ui/assets/{ts-tags-CWMzCstH.js → ts-tags-BO-Co0Ju.js} +1 -1
  115. package/dist/ui/assets/{tsx-DgzfiGeK.js → tsx-DtQe5AoH.js} +1 -1
  116. package/dist/ui/assets/{twig-Bi2itZL9.js → twig-BcUs6Kqc.js} +1 -1
  117. package/dist/ui/assets/{typescript-CQ24f4zW.js → typescript-BPtM8h4u.js} +1 -1
  118. package/dist/ui/assets/{vue-BS8jE5u6.js → vue-5KkGUzJJ.js} +1 -1
  119. package/dist/ui/assets/{vue-html-o38xqhyB.js → vue-html-Df_Xfsz9.js} +1 -1
  120. package/dist/ui/assets/{vue-vine-D2NvKv50.js → vue-vine-hUIe_u_Y.js} +1 -1
  121. package/dist/ui/assets/{xml-_44rx9_2.js → xml-C3Jy7as5.js} +1 -1
  122. package/dist/ui/assets/{xsl-B5QWQPoi.js → xsl-BnF9OuFo.js} +1 -1
  123. package/dist/ui/assets/{yaml-CKL2pEWe.js → yaml-DyrFgCou.js} +1 -1
  124. package/dist/workspaces/conversation-test-support.js +3 -11
  125. package/dist/workspaces/sessions.js +2 -0
  126. package/dist/workspaces/state/project-context.js +221 -0
  127. package/dist/workspaces.js +3 -1
  128. package/docs/chatgpt-coding-workflow.md +8 -3
  129. package/docs/configuration.md +99 -41
  130. package/docs/gotchas.md +24 -5
  131. package/docs/security.md +14 -3
  132. package/docs/setup.md +12 -7
  133. package/package.json +12 -3
  134. package/schemas/v1/config.project-local.schema.json +14 -0
  135. package/schemas/v1/config.project.schema.json +14 -0
  136. package/schemas/v1/config.user.schema.json +538 -0
  137. package/schemas/v1/hooks.project-local.schema.json +118 -0
  138. package/schemas/v1/hooks.project.schema.json +118 -0
  139. package/schemas/v1/hooks.user.schema.json +118 -0
  140. package/schemas/v1/language-servers.project-local.schema.json +230 -0
  141. package/schemas/v1/language-servers.project.schema.json +230 -0
  142. package/schemas/v1/language-servers.user.schema.json +230 -0
  143. package/schemas/v1/mcp.project-local.schema.json +143 -0
  144. package/schemas/v1/mcp.project.schema.json +143 -0
  145. package/schemas/v1/mcp.user.schema.json +143 -0
  146. package/scripts/ci/architecture.mjs +54 -4
  147. package/scripts/ci/cmd-acceptance.mjs +9 -9
  148. package/scripts/ci/config-v2-product-acceptance.mjs +639 -0
  149. package/scripts/ci/gates/acceptance-artifact.mjs +66 -0
  150. package/scripts/ci/gates/core-contract.mjs +27 -0
  151. package/scripts/ci/gates/platform-tests.mjs +38 -0
  152. package/scripts/ci/gates/prepare-windows-product.mjs +57 -0
  153. package/scripts/ci/gates/test-shard.mjs +71 -0
  154. package/scripts/ci/powershell51-acceptance.mjs +9 -9
  155. package/scripts/ci/pwsh-acceptance.mjs +9 -9
  156. package/scripts/ci/verify.mjs +2 -0
  157. package/scripts/ci/windows-product-acceptance.mjs +9 -10
  158. package/scripts/config/schema-text.mjs +4 -0
  159. package/scripts/config/schema-text.test.mjs +18 -0
  160. package/scripts/config/schema.mjs +74 -0
  161. package/scripts/lsp/interop.mjs +4 -1
  162. package/scripts/release/pack.mjs +64 -4
  163. package/scripts/release/release-gate.test.mjs +93 -27
  164. package/scripts/release-parity.mjs +19 -2
  165. package/templates/instructions/cmd.md +12 -0
  166. package/templates/instructions/fish.md +11 -0
  167. package/templates/instructions/powershell.md +14 -0
  168. package/templates/instructions/pwsh.md +14 -0
  169. package/templates/instructions/zsh.md +11 -0
  170. package/dist/runtime/instructions/powershell-skill.js +0 -65
@@ -1,16 +1,14 @@
1
- import { resolve } from "node:path";
2
- import { HOOK_EVENTS, loadProjectHookConfig, mergeHookConfigs, parseHookConfig, } from "./hooks.js";
3
- import { loadForgeRelayFiles } from "../../runtime/config/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");
1
+ import { join, resolve } from "node:path";
2
+ import { resolveProjectContext } from "../../workspaces/state/project-context.js";
3
+ import { effectiveHookConfigEntries, resolveHooksConfig, } from "../../runtime/config/resolution/hooks.js";
4
+ import { forgerelayConfigDir } from "../../runtime/config/user-config.js";
5
+ export async function checkHookConfiguration(projectRoot, globalHooks = {}, configDir, sourceRuntime) {
6
+ const resolution = await resolveHookConfiguration(projectRoot, globalHooks, configDir, sourceRuntime);
7
+ assertHookResolutionValid(resolution);
8
+ const entries = flattenResolvedHooks(resolution);
11
9
  return {
12
- globalHooks: globalEntries.length,
13
- projectHooks: projectEntries.length,
10
+ globalHooks: entries.filter((entry) => entry.scope === "global").length,
11
+ projectHooks: entries.filter((entry) => entry.scope === "project").length,
14
12
  };
15
13
  }
16
14
  export async function runHooksCommand(args) {
@@ -20,42 +18,75 @@ export async function runHooksCommand(args) {
20
18
  return;
21
19
  }
22
20
  const projectRoot = parseProjectRoot(rest);
23
- const globalHooks = loadGlobalHooks();
24
- const project = await loadProjectHookConfig(projectRoot);
25
- const globalEntries = flattenHooks(globalHooks, "global");
26
- const projectEntries = flattenHooks(project.hooks, "project");
21
+ const configDir = forgerelayConfigDir();
22
+ const resolution = await resolveHookConfiguration(projectRoot, {}, configDir);
23
+ const entries = flattenResolvedHooks(resolution);
27
24
  if (subcommand === "list") {
28
- for (const entry of [...globalEntries, ...projectEntries]) {
25
+ for (const entry of entries)
29
26
  console.log(formatHookEntry(entry));
30
- }
31
- if (globalEntries.length === 0 && projectEntries.length === 0) {
27
+ if (entries.length === 0)
32
28
  console.log("No hooks configured.");
33
- }
34
- if (project.diagnostic) {
35
- console.error(`Project hooks diagnostic: ${project.diagnostic}`);
29
+ for (const diagnostic of hookErrors(resolution)) {
30
+ console.error(`Hooks diagnostic: ${formatDiagnostic(diagnostic)}`);
36
31
  }
37
32
  return;
38
33
  }
39
34
  if (subcommand === "check") {
40
- if (project.diagnostic) {
41
- throw new Error(`Hook check failed: ${project.diagnostic}`);
42
- }
43
- console.log(`Hooks OK: ${globalEntries.length} global, ${projectEntries.length} project`);
35
+ assertHookResolutionValid(resolution);
36
+ const globalHooksCount = entries.filter((entry) => entry.scope === "global").length;
37
+ const projectHooksCount = entries.filter((entry) => entry.scope === "project").length;
38
+ console.log(`Hooks OK: ${globalHooksCount} global, ${projectHooksCount} project`);
44
39
  return;
45
40
  }
46
41
  throw new Error(`Unknown hooks command: ${subcommand}`);
47
42
  }
48
- function loadGlobalHooks() {
49
- const files = loadForgeRelayFiles();
50
- return mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles);
43
+ async function resolveHookConfiguration(projectRoot, legacyUser, configDir, sourceRuntime) {
44
+ const project = configDir ? await resolveProjectContext(configDir, projectRoot) : undefined;
45
+ return resolveHooksConfig({
46
+ ...(configDir ? { configDir } : {}),
47
+ ...(project ? { project } : {}),
48
+ projectSharedConfigDir: join(projectRoot, ".forgerelay"),
49
+ legacyUser,
50
+ ...(sourceRuntime ? { sourceRuntime } : {}),
51
+ });
52
+ }
53
+ function flattenResolvedHooks(resolution) {
54
+ const counters = new Map();
55
+ return effectiveHookConfigEntries(resolution).flatMap((entry) => entry.entries.map((hook) => {
56
+ const scope = entry.scope === "user" ? "global" : "project";
57
+ const counterKey = `${scope}:${hook.event}`;
58
+ const index = (counters.get(counterKey) ?? 0) + 1;
59
+ counters.set(counterKey, index);
60
+ const synthetic = entry.name.startsWith("@legacy/");
61
+ return {
62
+ scope,
63
+ event: hook.event,
64
+ name: hook.name ?? (synthetic ? `${hook.event} handler ${index}` : entry.name),
65
+ ...(hook.matcher ? { matcher: hook.matcher } : {}),
66
+ command: hook.command,
67
+ timeoutSeconds: hook.timeoutSeconds ?? 30,
68
+ report: hook.report ?? true,
69
+ };
70
+ }));
71
+ }
72
+ function hookErrors(resolution) {
73
+ return resolution.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
74
+ }
75
+ function assertHookResolutionValid(resolution) {
76
+ const errors = hookErrors(resolution);
77
+ if (errors.length === 0)
78
+ return;
79
+ throw new Error(`Hook check failed: ${errors.map(formatDiagnostic).join(" | ")}`);
80
+ }
81
+ function formatDiagnostic(diagnostic) {
82
+ return `${diagnostic.source.location ?? diagnostic.source.id}: ${diagnostic.message}`;
51
83
  }
52
84
  function parseProjectRoot(args) {
53
85
  let projectRoot = process.cwd();
54
86
  for (let index = 0; index < args.length; index += 1) {
55
87
  const arg = args[index];
56
- if (arg !== "--project") {
88
+ if (arg !== "--project")
57
89
  throw new Error(`Unknown hooks option: ${arg}`);
58
- }
59
90
  const value = args[index + 1];
60
91
  if (!value)
61
92
  throw new Error("Usage: forgerelay hooks <list|check> [--project <path>]");
@@ -64,27 +95,6 @@ function parseProjectRoot(args) {
64
95
  }
65
96
  return projectRoot;
66
97
  }
67
- function flattenHooks(config, scope) {
68
- const entries = [];
69
- for (const event of HOOK_EVENTS) {
70
- let handlerIndex = 0;
71
- for (const rule of config[event] ?? []) {
72
- for (const handler of rule.handlers) {
73
- handlerIndex += 1;
74
- entries.push({
75
- scope,
76
- event,
77
- name: handler.name ?? `${event} handler ${handlerIndex}`,
78
- matcher: rule.matcher,
79
- command: handler.command,
80
- timeoutSeconds: handler.timeoutSeconds,
81
- report: handler.report,
82
- });
83
- }
84
- }
85
- }
86
- return entries;
87
- }
88
98
  function formatHookEntry(entry) {
89
99
  const matcher = formatMatcher(entry.matcher);
90
100
  return `${entry.scope} ${entry.name} ${entry.event} ${matcher} timeout=${entry.timeoutSeconds}s report=${entry.report} :: ${entry.command}`;
@@ -1,27 +1,17 @@
1
- import { readFile, readdir } from "node:fs/promises";
2
1
  import { join } from "node:path";
3
2
  import { performance } from "node:perf_hooks";
4
3
  import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
5
- import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
4
+ import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime } from "../../runtime/shell/command-shell-runtime.js";
6
5
  import { resolveShellCommandForRuntime } from "../process/process-platform.js";
7
6
  import { executeHookCommand, hookFailureOutput } from "./command-runner.js";
8
- export const HOOK_EVENTS = [
9
- "WorkspaceOpen",
10
- "BeforeTool",
11
- "AfterTool",
12
- "AfterToolFailure",
13
- "ExternalMcpBeforeForward",
14
- "ExternalMcpAfterForward",
15
- "AfterFileChange",
16
- "BeforeWorktreeClose",
17
- "AfterWorktreeClose",
18
- "SubagentStart",
19
- "SubagentStop",
20
- ];
7
+ import { resolveProjectContext } from "../../workspaces/state/project-context.js";
8
+ import { effectiveHookConfigEntries, resolveHooksConfig } from "../../runtime/config/resolution/hooks.js";
9
+ import { compatibilityAllowProjectExecutionTrustPolicy, projectExecutionRequirement } from "../../runtime/security/project-execution-trust.js";
10
+ import { ConfigSourceRuntime } from "../../runtime/config/runtime/source-refresh.js";
11
+ import { HOOK_EVENTS } from "./config.js";
12
+ export { HOOK_EVENTS } from "./config.js";
21
13
  const DEFAULT_HOOK_TIMEOUT_SECONDS = 30;
22
14
  const MAX_HOOK_TIMEOUT_SECONDS = 300;
23
- const PROJECT_HOOKS_PATH = join(".forgerelay", "hooks.json");
24
- const PROJECT_HOOKS_DIR = join(".forgerelay", "hooks");
25
15
  const BLOCKING_EVENTS = new Set([
26
16
  "BeforeTool",
27
17
  "ExternalMcpBeforeForward",
@@ -215,17 +205,64 @@ function formatHookReports(executions) {
215
205
  }),
216
206
  ].join("\n");
217
207
  }
208
+ export async function resolveHookExecutionPlan(input) {
209
+ let projectRoot = input.event === "AfterWorktreeClose" && input.invocation.sourceRoot
210
+ ? input.invocation.sourceRoot
211
+ : input.invocation.workspaceRoot;
212
+ let project = input.project;
213
+ if (!project && input.configDir) {
214
+ try {
215
+ project = await resolveProjectContext(input.configDir, projectRoot);
216
+ }
217
+ catch (error) {
218
+ const sourceRoot = input.invocation.sourceRoot;
219
+ if (!sourceRoot || projectRoot === sourceRoot || !isMissingPath(error))
220
+ throw error;
221
+ projectRoot = sourceRoot;
222
+ project = await resolveProjectContext(input.configDir, projectRoot);
223
+ }
224
+ }
225
+ const resolution = await resolveHooksConfig({
226
+ ...(input.configDir ? { configDir: input.configDir } : {}),
227
+ ...(project ? { project } : {}),
228
+ projectSharedConfigDir: join(projectRoot, ".forgerelay"),
229
+ legacyUser: input.legacyUser, ...(input.sourceRuntime ? { sourceRuntime: input.sourceRuntime } : {}),
230
+ });
231
+ const handlers = effectiveHookConfigEntries(resolution).flatMap((entry) => entry.entries.flatMap((hook) => {
232
+ if (hook.event !== input.event)
233
+ return [];
234
+ const matchedInvocation = matchHookRule(hook.matcher, input.invocation);
235
+ if (!matchedInvocation)
236
+ return [];
237
+ const requirement = project ? projectExecutionRequirement({
238
+ projectId: project.id, resolution, entryKey: `hooks.${entry.name}`,
239
+ display: { kind: "hook", name: entry.name },
240
+ }) : undefined;
241
+ return [{
242
+ scope: entry.scope === "user" ? "global" : "project",
243
+ handler: resolvedHookHandler(entry.name, hook), invocation: matchedInvocation,
244
+ ...(requirement ? { executionRequirement: requirement } : {}),
245
+ }];
246
+ }));
247
+ return { handlers, resolution };
248
+ }
218
249
  export class HookRunner {
219
250
  hooks;
220
251
  logging;
221
252
  baseEnv;
222
253
  resultDecorator;
254
+ configDir;
255
+ sourceRuntime;
256
+ projectExecutionTrustPolicy;
223
257
  commandShellRuntime;
224
- constructor(hooks, logging, baseEnv = process.env, resultDecorator, commandShellRuntime) {
258
+ constructor(hooks, logging, baseEnv = process.env, resultDecorator, commandShellRuntime, configDir, sourceRuntime = new ConfigSourceRuntime(), projectExecutionTrustPolicy = compatibilityAllowProjectExecutionTrustPolicy) {
225
259
  this.hooks = hooks;
226
260
  this.logging = logging;
227
261
  this.baseEnv = baseEnv;
228
262
  this.resultDecorator = resultDecorator;
263
+ this.configDir = configDir;
264
+ this.sourceRuntime = sourceRuntime;
265
+ this.projectExecutionTrustPolicy = projectExecutionTrustPolicy;
229
266
  this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
230
267
  }
231
268
  decorateResult(workspaceId, result) {
@@ -233,34 +270,18 @@ export class HookRunner {
233
270
  }
234
271
  async run(event, invocation, signal) {
235
272
  signal?.throwIfAborted();
236
- const projectRoot = event === "AfterWorktreeClose" && invocation.sourceRoot
237
- ? invocation.sourceRoot
238
- : invocation.workspaceRoot;
239
- const project = await loadProjectHookConfig(projectRoot);
240
- const handlers = [
241
- ...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
242
- ...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
243
- ].flatMap(({ scope, rule }) => {
244
- const matchedInvocation = matchHookRule(rule.matcher, invocation);
245
- if (!matchedInvocation)
246
- return [];
247
- return rule.handlers.map((handler) => ({ scope, handler, invocation: matchedInvocation }));
273
+ const plan = await resolveHookExecutionPlan({
274
+ event,
275
+ invocation,
276
+ legacyUser: this.hooks,
277
+ ...(this.configDir ? { configDir: this.configDir } : {}), sourceRuntime: this.sourceRuntime,
248
278
  });
249
279
  const blocking = BLOCKING_EVENTS.has(event);
250
- const executions = project.diagnostic
251
- ? [{
252
- event,
253
- name: "Project hooks config",
254
- scope: "project",
255
- status: "failed",
256
- durationMs: 0,
257
- report: true,
258
- error: project.diagnostic,
259
- }]
260
- : [];
261
- for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
280
+ const executions = hookResolutionReports(plan.resolution, event);
281
+ const handlers = plan.handlers;
282
+ for (const [index, { scope, handler, invocation: matchedInvocation, executionRequirement }] of handlers.entries()) {
262
283
  signal?.throwIfAborted();
263
- const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, signal);
284
+ const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, executionRequirement, signal);
264
285
  executions.push(execution);
265
286
  logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
266
287
  hookEvent: event,
@@ -281,13 +302,15 @@ export class HookRunner {
281
302
  }
282
303
  return executions;
283
304
  }
284
- async runHandler(event, handler, index, invocation, scope, signal) {
305
+ async runHandler(event, handler, index, invocation, scope, executionRequirement, signal) {
285
306
  const startedAt = performance.now();
286
307
  const name = handler.name ?? `${event} handler ${index + 1}`;
287
308
  const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
288
309
  const detached = process.platform !== "win32";
289
310
  const env = hookEnvironment(this.baseEnv, event, invocation);
290
311
  try {
312
+ if (executionRequirement)
313
+ await this.projectExecutionTrustPolicy.authorize(executionRequirement);
291
314
  const result = await executeHookCommand({
292
315
  executable: shell.executable,
293
316
  args: shell.args,
@@ -340,12 +363,54 @@ export class HookRunner {
340
363
  }
341
364
  }
342
365
  }
366
+ function resolvedHookHandler(name, hook) {
367
+ const legacySyntheticName = name.startsWith("@legacy/");
368
+ return {
369
+ ...((hook.name ?? (!legacySyntheticName ? name : undefined))
370
+ ? { name: hook.name ?? name }
371
+ : {}),
372
+ command: hook.command,
373
+ timeoutSeconds: hook.timeoutSeconds ?? DEFAULT_HOOK_TIMEOUT_SECONDS,
374
+ report: hook.report ?? true,
375
+ };
376
+ }
377
+ function hookResolutionReports(resolution, event) {
378
+ const grouped = new Map();
379
+ for (const diagnostic of resolution.diagnostics) {
380
+ if (diagnostic.severity !== "error" || diagnostic.diagnosticChanged === false)
381
+ continue;
382
+ const scope = diagnostic.source.scope === "user"
383
+ ? "global"
384
+ : diagnostic.source.scope === "project" || diagnostic.source.scope === "project-local"
385
+ ? "project"
386
+ : undefined;
387
+ if (!scope)
388
+ continue;
389
+ const location = diagnostic.source.location ?? diagnostic.source.id;
390
+ grouped.set(scope, [...(grouped.get(scope) ?? []), `${location}: ${diagnostic.message}`]);
391
+ }
392
+ return ["global", "project"].flatMap((scope) => {
393
+ const errors = grouped.get(scope);
394
+ if (!errors?.length)
395
+ return [];
396
+ return [{
397
+ event,
398
+ name: scope === "global" ? "Global hooks config" : "Project hooks config",
399
+ scope,
400
+ status: "failed",
401
+ durationMs: 0,
402
+ report: true,
403
+ error: errors.join(" | "),
404
+ }];
405
+ });
406
+ }
343
407
  function parseHookRule(event, value, index) {
344
408
  if (!isRecord(value)) {
345
409
  throw new Error(`Hook ${event} entry ${index + 1} must be an object`);
346
410
  }
347
411
  if (!("handlers" in value)) {
348
412
  return {
413
+ matcher: parseHookMatcher(event, value.matcher, index),
349
414
  handlers: [parseHookHandler(event, value, index)],
350
415
  };
351
416
  }
@@ -467,45 +532,6 @@ function assertValidRegex(event, field, pattern) {
467
532
  throw new Error(`Hook ${event} matcher ${field} must be a valid regular expression`);
468
533
  }
469
534
  }
470
- export async function loadProjectHookConfig(workspaceRoot) {
471
- let hooks = {};
472
- const diagnostics = [];
473
- const aggregatePath = join(workspaceRoot, PROJECT_HOOKS_PATH);
474
- try {
475
- const content = await readFile(aggregatePath, "utf8");
476
- hooks = mergeHookConfigs(hooks, parseHookConfig(JSON.parse(content)));
477
- }
478
- catch (error) {
479
- if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
480
- diagnostics.push(`Could not load project hooks at ${aggregatePath}: ${errorMessage(error)}`);
481
- }
482
- }
483
- const directory = join(workspaceRoot, PROJECT_HOOKS_DIR);
484
- try {
485
- const entries = (await readdir(directory, { withFileTypes: true }))
486
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
487
- .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
488
- for (const entry of entries) {
489
- const path = join(directory, entry.name);
490
- try {
491
- const value = JSON.parse(await readFile(path, "utf8"));
492
- hooks = mergeHookConfigs(hooks, parseHookFile(value, entry.name.slice(0, -5)));
493
- }
494
- catch (error) {
495
- diagnostics.push(`Could not load project hook at ${path}: ${errorMessage(error)}`);
496
- }
497
- }
498
- }
499
- catch (error) {
500
- if (!(isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))) {
501
- diagnostics.push(`Could not read project hook directory at ${directory}: ${errorMessage(error)}`);
502
- }
503
- }
504
- return {
505
- hooks,
506
- ...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
507
- };
508
- }
509
535
  export function matchHookRule(matcher, invocation) {
510
536
  if (!matcher)
511
537
  return invocation;
@@ -580,8 +606,8 @@ function hookEnvironment(baseEnv, event, invocation) {
580
606
  function errorMessage(error) {
581
607
  return error instanceof Error ? error.message : String(error);
582
608
  }
583
- function isErrnoException(error) {
584
- return error instanceof Error && "code" in error;
609
+ function isMissingPath(error) {
610
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
585
611
  }
586
612
  function isRecord(value) {
587
613
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2,7 +2,7 @@ import { ExternalMcpConfigRegistry } from "../../../runtime/config/external-mcp-
2
2
  import { ExternalMcpCredentialStore } from "../../../runtime/config/external-mcp-auth-store.js";
3
3
  import { logEvent } from "../../../runtime/logging/logger.js";
4
4
  import { CapabilityError } from "../../server/core/capability-registry.js";
5
- import { requireCapabilityWorkspaceRoot } from "../../server/core/capability-support.js";
5
+ import { requireCapabilityProject, requireCapabilityWorkspaceRoot, } from "../../server/core/capability-support.js";
6
6
  import { ExternalMcpTransformError, ExternalMcpTransformRunner, } from "../../hooks/external-mcp-transform.js";
7
7
  import { ExternalMcpError, ExternalMcpGateway } from "./external-mcp.js";
8
8
  export function createExternalMcpCapabilityRuntime(config) {
@@ -10,24 +10,26 @@ export function createExternalMcpCapabilityRuntime(config) {
10
10
  const externalMcp = new ExternalMcpGateway(config.mediaMaxBytes, credentialStore);
11
11
  const registry = new ExternalMcpConfigRegistry({
12
12
  configDir: config.configDir,
13
- legacyServers: config.mcpServers,
13
+ sourceRuntime: config.configRuntime.sources,
14
14
  onDiagnostic: (diagnostic) => logEvent(config.logging, "warn", "external_mcp_config_invalid", {
15
15
  source: diagnostic.source,
16
16
  path: diagnostic.path,
17
17
  reason: diagnostic.message,
18
18
  }),
19
19
  });
20
- const transforms = new ExternalMcpTransformRunner(config.hooks, config.logging, process.env, config.commandShellRuntime, config.mediaMaxBytes);
20
+ const transforms = new ExternalMcpTransformRunner({}, config.logging, process.env, config.commandShellRuntime, config.mediaMaxBytes, config.configDir, config.configRuntime.sources);
21
21
  return {
22
22
  available: true,
23
23
  run: async (input, context, runOptions) => {
24
24
  try {
25
25
  const workspaceRoot = requireCapabilityWorkspaceRoot(context);
26
- const snapshot = registry.resolve(workspaceRoot);
26
+ const project = requireCapabilityProject(context);
27
+ const snapshot = registry.resolve(project);
27
28
  const transformContext = (server, tool) => ({
28
29
  workspaceId: context.workspaceId,
29
30
  workspaceRoot,
30
31
  workspaceMode: context.workspaceMode,
32
+ project,
31
33
  server,
32
34
  tool,
33
35
  });
@@ -37,8 +39,9 @@ export function createExternalMcpCapabilityRuntime(config) {
37
39
  result: (server, tool, externalResult) => transforms.transformResult(transformContext(server, tool), externalResult, runOptions.signal),
38
40
  }
39
41
  : undefined, {
40
- workspaceRoot,
42
+ project: { id: project.id, projectRoot: project.projectRoot },
41
43
  origins: snapshot.origins,
44
+ executionRequirements: snapshot.executionRequirements,
42
45
  });
43
46
  return {
44
47
  value: result.value,
@@ -3,6 +3,7 @@ import { AuthorizationServerMismatchError, Client, InsufficientScopeError, SdkHt
3
3
  import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio";
4
4
  import { CallToolResultSchema } from "@modelcontextprotocol/core";
5
5
  import { externalMcpCredentialIdentity, } from "../../../runtime/config/external-mcp-auth-store.js";
6
+ import { compatibilityAllowProjectExecutionTrustPolicy, } from "../../../runtime/security/project-execution-trust.js";
6
7
  import { claimMediaBytes, createMediaBudget, isSupportedImageMimeType, strictBase64ByteLength, } from "../media-content.js";
7
8
  import { ExternalMcpTransformError, } from "../../hooks/external-mcp-transform.js";
8
9
  import { ExternalMcpOAuthError, createExternalMcpRuntimeAuth, externalMcpAuthError, markExternalMcpAuthorizationRequired, markExternalMcpReauthorization, } from "./external-mcp-oauth.js";
@@ -24,10 +25,12 @@ export class ExternalMcpError extends Error {
24
25
  export class ExternalMcpGateway {
25
26
  mediaMaxBytes;
26
27
  credentialStore;
28
+ projectExecutionTrustPolicy;
27
29
  negotiationCache = new Map();
28
- constructor(mediaMaxBytes, credentialStore) {
30
+ constructor(mediaMaxBytes, credentialStore, projectExecutionTrustPolicy = compatibilityAllowProjectExecutionTrustPolicy) {
29
31
  this.mediaMaxBytes = mediaMaxBytes;
30
32
  this.credentialStore = credentialStore;
33
+ this.projectExecutionTrustPolicy = projectExecutionTrustPolicy;
31
34
  }
32
35
  async run(servers, input, signal, transforms, authContext) {
33
36
  signal?.throwIfAborted();
@@ -121,6 +124,10 @@ export class ExternalMcpGateway {
121
124
  throw new ExternalMcpError("unknown_server", `Unknown configured external MCP server: ${name}.`);
122
125
  const fingerprint = externalMcpServerFingerprint(config);
123
126
  const runtimeAuth = this.resolveRuntimeAuth(name, config, authContext);
127
+ const requirement = authContext?.executionRequirements?.[name];
128
+ if (config.transport === "stdio" && requirement) {
129
+ await this.projectExecutionTrustPolicy.authorize(requirement);
130
+ }
124
131
  const client = new Client({ name: "forgerelay-external-mcp", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
125
132
  const transport = createTransport(config, runtimeAuth?.authProvider);
126
133
  const prior = this.cachedPrior(name, fingerprint);
@@ -169,7 +176,7 @@ export class ExternalMcpGateway {
169
176
  const source = context.origins[name];
170
177
  if (!source)
171
178
  return undefined;
172
- const identity = externalMcpCredentialIdentity(source, name, context.workspaceRoot);
179
+ const identity = externalMcpCredentialIdentity(source, name, context.project);
173
180
  return createExternalMcpRuntimeAuth(this.credentialStore, identity, config.url);
174
181
  }
175
182
  cachedPrior(name, fingerprint) {
@@ -16,6 +16,7 @@ export function capabilityContextFor(workspace) {
16
16
  workspaceRoot: workspace.root,
17
17
  workspaceMode: workspace.mode,
18
18
  workspaceManaged: workspace.worktree?.managed ?? false,
19
+ ...(workspace.project ? { project: workspace.project } : {}),
19
20
  guides: workspace.capabilityGuides.map((guide) => ({
20
21
  name: guide.name,
21
22
  description: guide.description,
@@ -56,6 +57,12 @@ export function requireCapabilityWorkspaceRoot(context) {
56
57
  }
57
58
  return context.workspaceRoot;
58
59
  }
60
+ export function requireCapabilityProject(context) {
61
+ if (!context.project) {
62
+ throw new CapabilityError("capability_unavailable", `Capability execution requires Project identity for Workspace ${context.workspaceId}.`);
63
+ }
64
+ return context.project;
65
+ }
59
66
  export function runWorkspaceTasksCapability(store, workspaceId, input) {
60
67
  switch (input.operation) {
61
68
  case "get":