@akira-tl/forgerelay 0.1.0 → 0.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-08-09
8
+
9
+ ### Added
10
+
11
+ - A checked-in `127.0.0.1:7677` local debug environment with reproducible OAuth/MCP acceptance scripts, isolated runtime state, and Hooks v1 lifecycle recording.
12
+ - 用户或 Agent 可写的生命周期 Hook,覆盖 workspace、MCP tool、明确文件变更、managed-worktree close 与本地 subagent 生命周期。
13
+ - 首选的一 Hook 一文件配置:全局 `hooks/<hook-name>.json` 与项目 `.forgerelay/hooks/<hook-name>.json` 自动组合,文件名直接作为 Hook 名并按文件名稳定排序;旧 inline/聚合格式继续兼容。
14
+ - 独立 Hook 的 `event + matcher + command`、bounded timeout 与 `report` 配置;可报告结果进入 Agent 可见返回,阻断失败始终可见。
15
+ - 只读的 `forgerelay hooks list` / `hooks check` CLI,用于查看实际加载规则和在不执行 Hook 的情况下校验全局/项目配置。
16
+ - `BeforeTool` 与 `BeforeWorktreeClose` 阻断语义,以及不会伪装回滚已完成操作的 observational after-events。
17
+ - 通过 `FORGERELAY_HOOK_*` 和 workspace 环境变量提供生命周期上下文,同时避免暴露 native-file credentials 或 subagent prompts。
18
+ - 异步 subagent Hook report 的 session 持久化与 `agents show` 展示。
19
+
20
+ ### Fixed
21
+
22
+ - MCP `initialize` now reports the package version from `package.json` instead of a stale hardcoded `0.1.0` server version.
23
+ - Hook commands on Windows preserve quoted arguments when executed through `cmd.exe`, fixing release-gate and other Hook commands that reference absolute paths.
24
+
25
+ ### Security
26
+
27
+ - 项目 Hook 作为 allowed-root 内项目执行约定自动生效,不需要批准;Hook 文件只能声明生命周期规则,不能扩大 allowed roots、修改认证配置或覆盖全局规则。
28
+ - 无效项目 Hook 配置会作为 Agent 可见 diagnostic 返回,同时保持工具可用;独立目录中的坏文件会被跳过,其他有效 Hook 继续加载,避免 Agent 因写坏单个规则而无法修复。
29
+
30
+ ## [0.1.1] - 2026-08-09
31
+
32
+ ### Changed
33
+
34
+ - GitHub CI is now release-only: ordinary branch pushes, pull-request updates, and manual dispatches do not start cloud CI; the reusable CI workflow is invoked only by the stable `vX.Y.Z` tag release workflow.
35
+ - Release automation now triggers only for stable `vX.Y.Z` version tags, waits for cloud CI to pass, then publishes; ordinary branch pushes never enter the CI or publication workflows.
36
+ - Global system instructions now come from exactly one configured file, defaulting to `~/.agents/AGENTS.md`; `FORGERELAY_AGENT_DIR` remains a skill-compatibility path rather than an instruction source.
37
+
38
+ ### Fixed
39
+
40
+ - Symbolic-link system instruction entries now follow their configured target even when the canonical source lives outside the runtime instruction directory.
41
+ - Project instruction aliases that resolve to the same file are loaded once, preventing duplicate `AGENTS.md` context on case-insensitive filesystems such as default macOS and Windows checkouts.
42
+
7
43
  ## [0.1.0] - 2026-08-09
8
44
 
9
45
  ### Added
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **Give MCP coding agents a real local workspace.**
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/%40akira-tl%2Fforgerelay?style=flat-square)](https://www.npmjs.com/package/@akira-tl/forgerelay)
6
- [![CI](https://img.shields.io/github/actions/workflow/status/Akira-TL/forgerelay/ci.yml?style=flat-square&branch=main)](https://github.com/Akira-TL/forgerelay/actions/workflows/ci.yml)
6
+ [![Release](https://img.shields.io/github/actions/workflow/status/Akira-TL/forgerelay/release.yml?style=flat-square&label=release)](https://github.com/Akira-TL/forgerelay/actions/workflows/release.yml)
7
7
  [![License](https://img.shields.io/npm/l/%40akira-tl%2Fforgerelay?style=flat-square)](LICENSE)
8
8
 
9
9
  ForgeRelay is a self-hosted MCP server that lets ChatGPT and other MCP-capable
@@ -90,7 +90,9 @@ Once a workspace is open, the host can:
90
90
  parallel work;
91
91
  - close a managed worktree by committing its remaining changes and
92
92
  fast-forwarding the target branch when that can be done safely;
93
- - show aggregate changes through optional ChatGPT Apps-compatible UI cards.
93
+ - show aggregate changes through optional ChatGPT Apps-compatible UI cards;
94
+ - run user-configured lifecycle hooks around tool calls, file changes, managed
95
+ worktree close, and local subagent execution.
94
96
 
95
97
  Normal work happens in your existing checkout. ForgeRelay does not silently move
96
98
  every task into a worktree.
@@ -117,6 +119,39 @@ If the histories have diverged, the close is refused and the worktree is left in
117
119
  place. ForgeRelay does not put the source checkout into a merge-conflict state.
118
120
  You can rebase and verify inside the worktree, then retry the close.
119
121
 
122
+ ## Lifecycle hooks
123
+
124
+ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一个文件:全局放在 `~/.forgerelay/hooks/<hook-name>.json`,项目放在 `<repo>/.forgerelay/hooks/<hook-name>.json`。文件名就是 Hook 名,方便直接从目录看出每条规则的用途;全局与项目规则组合执行,不需要额外批准。
125
+
126
+ 例如项目里的 `.forgerelay/hooks/release-tag-local-ci.json` 可以要求稳定版本 tag push 前先完成本地发布检查:
127
+
128
+ ```json
129
+ {
130
+ "event": "BeforeTool",
131
+ "matcher": {
132
+ "tool": "bash",
133
+ "commandRegex": "^git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+$"
134
+ },
135
+ "command": "npm run release:verify",
136
+ "timeoutSeconds": 300,
137
+ "report": true
138
+ }
139
+ ```
140
+
141
+ 命中 `BeforeTool` 后,Hook 先执行;成功才继续原始 `git push`,失败则直接阻断。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
142
+
143
+ 旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
144
+
145
+ 可以直接检查当前全局与项目规则,而不会执行 Hook:
146
+
147
+ ```bash
148
+ forgerelay hooks list
149
+ forgerelay hooks check
150
+ forgerelay hooks list --project /path/to/project
151
+ ```
152
+
153
+ `list` 展示实际加载的规则、matcher、timeout、report 与 command;`check` 只校验配置并在发现坏文件时返回非零状态。完整 matcher、事件与环境变量见 [Configuration Reference](docs/configuration.md#lifecycle-hooks)。
154
+
120
155
  ## Local coding agents
121
156
 
122
157
  ForgeRelay can delegate work to local coding runtimes through user-defined
@@ -200,11 +235,10 @@ forgerelay doctor
200
235
  The next additions are focused on making the local execution layer more useful,
201
236
  not on turning ForgeRelay into another all-in-one agent framework:
202
237
 
203
- 1. lifecycle hooks;
204
- 2. LSP-backed code intelligence;
205
- 3. first-class MCP subagent delegation;
206
- 4. stronger worktree verification and recovery;
207
- 5. checkpoint/rewind and retention improvements.
238
+ 1. LSP-backed code intelligence;
239
+ 2. first-class MCP subagent delegation;
240
+ 3. stronger worktree verification and recovery;
241
+ 4. checkpoint/rewind and retention improvements.
208
242
 
209
243
  ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
210
244
  or plugin marketplace. Conversation, planning, web access, and other host-native
@@ -227,9 +261,11 @@ npm run release:major
227
261
  npm run release:verify
228
262
  ```
229
263
 
230
- Pushing a matching `vX.Y.Z` tag to `Akira-TL/forgerelay` is the publish action.
231
- GitHub Actions verifies the tagged commit, publishes `@akira-tl/forgerelay` to
232
- npm, and creates the matching GitHub Release.
264
+ Daily branch pushes do not run cloud CI. When preparing a release, run the full
265
+ local release verification first. Pushing a matching `vX.Y.Z` tag to
266
+ `Akira-TL/forgerelay` is the only cloud CI and publish trigger: GitHub Actions
267
+ runs the reusable multi-platform CI, then publishes `@akira-tl/forgerelay` and
268
+ creates the matching GitHub Release only after CI succeeds.
233
269
 
234
270
  See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
235
271
  Trusted Publishing setup.
@@ -237,6 +273,7 @@ Trusted Publishing setup.
237
273
  ## Documentation
238
274
 
239
275
  - [Setup Guide](docs/setup.md)
276
+ - [Local Debugging and 7677 Acceptance](docs/debugging.md)
240
277
  - [ChatGPT Coding Workflow](docs/chatgpt-coding-workflow.md)
241
278
  - [Configuration Reference](docs/configuration.md)
242
279
  - [Agent Profile Schema](docs/agent-profile-schema.md)
@@ -264,7 +301,17 @@ The original copyright notice remains in [LICENSE](LICENSE). See
264
301
  ```bash
265
302
  npm install --include=dev
266
303
  npm run dev
304
+ npm run debug:accept
267
305
  npm run typecheck
268
306
  npm test
269
307
  npm run build
270
308
  ```
309
+
310
+ `npm run dev` uses the checked-in local debug configuration and binds ForgeRelay
311
+ to `127.0.0.1:7677`, keeping the normal `7676` product port free. The
312
+ `debug:accept` command starts a temporary 7677 server and sends real
313
+ HTTP/OAuth/MCP requests through it, including workspace tools, managed worktree
314
+ close, and Hooks v1 lifecycle recording. Debug state stays under the gitignored
315
+ `.forgerelay-debug/` directory.
316
+
317
+ See [Local Debugging](docs/debugging.md) for the exact configuration and scripts.
@@ -5,6 +5,7 @@ import { isAbsolute, join, normalize, sep } from "node:path";
5
5
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
6
6
  import * as z from "zod/v4";
7
7
  import { ArtifactError } from "./artifact-error.js";
8
+ import { runToolWithHooks } from "./hooks.js";
8
9
  import { describeIncomingArtifactValue, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
9
10
  import { logEvent } from "./logger.js";
10
11
  const ARTIFACT_WRITE_ANNOTATIONS = {
@@ -31,7 +32,7 @@ const openAIFileReferenceInputSchema = z.strictObject({
31
32
  export function isArtifactDownloadSupportedPlatform(platform = process.platform) {
32
33
  return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform);
33
34
  }
34
- export function registerArtifactTools(server, { config, workspaces, incomingArtifactAdapters = [], }) {
35
+ export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], }) {
35
36
  const incomingRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
36
37
  registerAppTool(server, "download_artifact", {
37
38
  title: "Download attached or generated file",
@@ -46,21 +47,34 @@ export function registerArtifactTools(server, { config, workspaces, incomingArti
46
47
  },
47
48
  _meta: { "openai/fileParams": ["file"] },
48
49
  annotations: ARTIFACT_WRITE_ANNOTATIONS,
49
- }, async (input) => executeArtifactTool(config, input, async () => {
50
+ }, async (input) => {
50
51
  const workspace = workspaces.getWorkspace(input.workspaceId);
51
- const downloaded = await downloadIncomingArtifact({
52
- registry: incomingRegistry,
53
- workspaceId: workspace.id,
54
- workspaceRoot: workspace.root,
55
- maxFileBytes: config.artifactMaxFileBytes,
56
- file: input.file,
57
- path: input.path,
52
+ return runToolWithHooks(hooks, {
53
+ tool: "download_artifact",
54
+ invocation: {
55
+ workspaceId: workspace.id,
56
+ workspaceRoot: workspace.root,
57
+ workspaceMode: workspace.mode,
58
+ sourceRoot: workspace.sourceRoot,
59
+ },
60
+ payload: { path: input.path },
61
+ changedPaths: (result) => [result.structuredContent.path],
62
+ operation: () => executeArtifactTool(config, input, async () => {
63
+ const downloaded = await downloadIncomingArtifact({
64
+ registry: incomingRegistry,
65
+ workspaceId: workspace.id,
66
+ workspaceRoot: workspace.root,
67
+ maxFileBytes: config.artifactMaxFileBytes,
68
+ file: input.file,
69
+ path: input.path,
70
+ });
71
+ return {
72
+ publicResult: { path: downloaded.path },
73
+ logResult: downloaded,
74
+ };
75
+ }),
58
76
  });
59
- return {
60
- publicResult: { path: downloaded.path },
61
- logResult: downloaded,
62
- };
63
- }));
77
+ });
64
78
  }
65
79
  /**
66
80
  * Stream a trusted native file directly into one already-open workspace.
package/dist/cli.js CHANGED
@@ -11,6 +11,8 @@ import * as prompts from "@clack/prompts";
11
11
  import { getShellConfig } from "@earendil-works/pi-coding-agent";
12
12
  import { satisfies } from "semver";
13
13
  import { loadConfig } from "./config.js";
14
+ import { formatVisibleHookReports, HookRunner } from "./hooks.js";
15
+ import { runHooksCommand } from "./hook-cli.js";
14
16
  import { runLocalAgentProvider } from "./local-agent-adapters.js";
15
17
  import { isLocalAgentProvider, loadLocalAgentProfiles, } from "./local-agent-profiles.js";
16
18
  import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js";
@@ -39,6 +41,9 @@ async function main(argv) {
39
41
  case "config":
40
42
  runConfigCommand(args);
41
43
  return;
44
+ case "hooks":
45
+ await runHooksCommand(args);
46
+ return;
42
47
  case "agents":
43
48
  await runAgentsCommand(args);
44
49
  return;
@@ -53,7 +58,7 @@ async function main(argv) {
53
58
  function normalizeCommand(command) {
54
59
  if (!command || command === "serve" || command === "start")
55
60
  return "serve";
56
- if (command === "init" || command === "doctor" || command === "config" || command === "agents")
61
+ if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents")
57
62
  return command;
58
63
  if (command === "help" || command === "--help" || command === "-h")
59
64
  return "help";
@@ -261,6 +266,8 @@ function printHelp() {
261
266
  " forgerelay doctor Show config, runtime, and native dependency status",
262
267
  " forgerelay config get Print persisted config",
263
268
  " forgerelay config set publicBaseUrl <url|null>",
269
+ " forgerelay hooks list [--project <path>]",
270
+ " forgerelay hooks check [--project <path>]",
264
271
  " forgerelay agents ls List subagent sessions",
265
272
  " forgerelay agents run <profile-or-provider-or-id> [--model <model>] <prompt>",
266
273
  " forgerelay agents show <id>",
@@ -326,6 +333,7 @@ async function runAgentsRun(args) {
326
333
  thinking: parsed.thinking ?? existing.thinking,
327
334
  latestResponse: undefined,
328
335
  error: undefined,
336
+ hookReports: undefined,
329
337
  });
330
338
  spawnAgentWorker(existing.id, promptFile);
331
339
  console.log(formatAgentLine({
@@ -369,6 +377,9 @@ async function runAgentsShow(args) {
369
377
  record = store.get(id) ?? record;
370
378
  }
371
379
  console.log(formatAgentLine(record));
380
+ const hookSummary = formatVisibleHookReports(record.hookReports ?? []);
381
+ if (hookSummary)
382
+ console.log(hookSummary);
372
383
  if (record.latestResponse) {
373
384
  console.log(record.latestResponse);
374
385
  return;
@@ -391,7 +402,21 @@ async function runAgentsWorker(args) {
391
402
  const record = store.get(id);
392
403
  if (!record)
393
404
  throw new Error(`Unknown subagent id: ${id}`);
394
- store.update(record.id, { status: "running", error: undefined });
405
+ const hooks = new HookRunner(config.hooks, config.logging);
406
+ const hookInvocation = {
407
+ workspaceId: record.workspaceId,
408
+ workspaceRoot: record.workspaceRoot,
409
+ payload: {
410
+ agentId: record.id,
411
+ profile: record.profileName,
412
+ provider: record.provider,
413
+ model: record.model,
414
+ thinking: record.thinking,
415
+ },
416
+ };
417
+ store.update(record.id, { status: "running", error: undefined, hookReports: undefined });
418
+ const hookReports = await hooks.run("SubagentStart", hookInvocation);
419
+ store.update(record.id, { hookReports });
395
420
  try {
396
421
  const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
397
422
  const profile = profiles.find((candidate) => candidate.name === record.profileName);
@@ -399,17 +424,35 @@ async function runAgentsWorker(args) {
399
424
  const result = profile
400
425
  ? await runLocalAgentProfile(profile, record, prompt)
401
426
  : await runRawLocalAgentProvider(record, prompt);
427
+ hookReports.push(...await hooks.run("SubagentStop", {
428
+ ...hookInvocation,
429
+ payload: {
430
+ ...hookInvocation.payload,
431
+ status: "idle",
432
+ providerSessionId: result.providerSessionId,
433
+ },
434
+ }));
402
435
  store.update(record.id, {
403
436
  providerSessionId: result.providerSessionId ?? undefined,
404
437
  status: "idle",
405
438
  latestResponse: result.finalResponse,
406
439
  error: undefined,
440
+ hookReports,
407
441
  });
408
442
  }
409
443
  catch (error) {
444
+ const message = error instanceof Error ? error.message : String(error);
445
+ hookReports.push(...await hooks.run("SubagentStop", {
446
+ ...hookInvocation,
447
+ payload: {
448
+ ...hookInvocation.payload,
449
+ status: "error",
450
+ },
451
+ }));
410
452
  store.update(record.id, {
411
453
  status: "error",
412
- error: error instanceof Error ? error.message : String(error),
454
+ error: message,
455
+ hookReports,
413
456
  });
414
457
  }
415
458
  }
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "./roots.js";
5
+ import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
5
6
  import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles } from "./user-config.js";
6
7
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
7
8
  const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
@@ -164,6 +165,17 @@ function defaultWorktreeRoot() {
164
165
  function defaultAgentDir() {
165
166
  return join(homedir(), ".codex");
166
167
  }
168
+ function defaultSystemInstructionsPath() {
169
+ return join(homedir(), ".agents", "AGENTS.md");
170
+ }
171
+ function parseSystemInstructionsPath(value) {
172
+ if (value === undefined)
173
+ return resolve(defaultSystemInstructionsPath());
174
+ if (typeof value !== "string" || value.trim().length === 0) {
175
+ throw new Error("FORGERELAY_SYSTEM_INSTRUCTIONS_PATH must be one non-empty path");
176
+ }
177
+ return resolve(expandHomePath(value.trim()));
178
+ }
167
179
  export function loadConfig(env = process.env) {
168
180
  const files = loadForgeRelayFiles(env);
169
181
  const host = env.HOST ?? files.config.host ?? "127.0.0.1";
@@ -202,6 +214,8 @@ export function loadConfig(env = process.env) {
202
214
  ? files.config.subagents === true
203
215
  : parseBoolean(productEnv(env, "SUBAGENTS")),
204
216
  agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
217
+ systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
218
+ hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
205
219
  logging: parseLoggingConfig(env),
206
220
  };
207
221
  }
@@ -24,6 +24,11 @@ const migrations = [
24
24
  name: "workspace-worktree-branches",
25
25
  up: migrateWorkspaceWorktreeBranches,
26
26
  },
27
+ {
28
+ version: 6,
29
+ name: "local-agent-hook-reports",
30
+ up: migrateLocalAgentHookReports,
31
+ },
27
32
  ];
28
33
  export function migrateDatabase(sqlite) {
29
34
  const migrate = sqlite.transaction(() => {
@@ -181,6 +186,9 @@ function migrateWorkspaceWorktreeBranches(sqlite) {
181
186
  addColumnIfMissing(sqlite, "workspace_sessions", "branch", "text");
182
187
  addColumnIfMissing(sqlite, "workspace_sessions", "target_branch", "text");
183
188
  }
189
+ function migrateLocalAgentHookReports(sqlite) {
190
+ addColumnIfMissing(sqlite, "local_agent_sessions", "hook_reports_json", "text");
191
+ }
184
192
  function addColumnIfMissing(sqlite, table, column, definition) {
185
193
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
186
194
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -76,6 +76,7 @@ export const localAgentSessions = sqliteTable("local_agent_sessions", {
76
76
  status: text("status").notNull(),
77
77
  latestResponse: text("latest_response"),
78
78
  error: text("error"),
79
+ hookReportsJson: text("hook_reports_json"),
79
80
  createdAt: text("created_at").notNull(),
80
81
  updatedAt: text("updated_at").notNull(),
81
82
  }, (table) => [
@@ -0,0 +1,100 @@
1
+ import { resolve } from "node:path";
2
+ import { HOOK_EVENTS, loadProjectHookConfig, mergeHookConfigs, parseHookConfig, } from "./hooks.js";
3
+ import { loadForgeRelayFiles } from "./user-config.js";
4
+ export async function runHooksCommand(args) {
5
+ const [subcommand, ...rest] = args;
6
+ if (!subcommand || ["help", "--help", "-h"].includes(subcommand)) {
7
+ printHooksHelp();
8
+ return;
9
+ }
10
+ const projectRoot = parseProjectRoot(rest);
11
+ const globalHooks = loadGlobalHooks();
12
+ const project = await loadProjectHookConfig(projectRoot);
13
+ const globalEntries = flattenHooks(globalHooks, "global");
14
+ const projectEntries = flattenHooks(project.hooks, "project");
15
+ if (subcommand === "list") {
16
+ for (const entry of [...globalEntries, ...projectEntries]) {
17
+ console.log(formatHookEntry(entry));
18
+ }
19
+ if (globalEntries.length === 0 && projectEntries.length === 0) {
20
+ console.log("No hooks configured.");
21
+ }
22
+ if (project.diagnostic) {
23
+ console.error(`Project hooks diagnostic: ${project.diagnostic}`);
24
+ }
25
+ return;
26
+ }
27
+ if (subcommand === "check") {
28
+ if (project.diagnostic) {
29
+ throw new Error(`Hook check failed: ${project.diagnostic}`);
30
+ }
31
+ console.log(`Hooks OK: ${globalEntries.length} global, ${projectEntries.length} project`);
32
+ return;
33
+ }
34
+ throw new Error(`Unknown hooks command: ${subcommand}`);
35
+ }
36
+ function loadGlobalHooks() {
37
+ const files = loadForgeRelayFiles();
38
+ return mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles);
39
+ }
40
+ function parseProjectRoot(args) {
41
+ let projectRoot = process.cwd();
42
+ for (let index = 0; index < args.length; index += 1) {
43
+ const arg = args[index];
44
+ if (arg !== "--project") {
45
+ throw new Error(`Unknown hooks option: ${arg}`);
46
+ }
47
+ const value = args[index + 1];
48
+ if (!value)
49
+ throw new Error("Usage: forgerelay hooks <list|check> [--project <path>]");
50
+ projectRoot = resolve(value);
51
+ index += 1;
52
+ }
53
+ return projectRoot;
54
+ }
55
+ function flattenHooks(config, scope) {
56
+ const entries = [];
57
+ for (const event of HOOK_EVENTS) {
58
+ let handlerIndex = 0;
59
+ for (const rule of config[event] ?? []) {
60
+ for (const handler of rule.handlers) {
61
+ handlerIndex += 1;
62
+ entries.push({
63
+ scope,
64
+ event,
65
+ name: handler.name ?? `${event} handler ${handlerIndex}`,
66
+ matcher: rule.matcher,
67
+ command: handler.command,
68
+ timeoutSeconds: handler.timeoutSeconds,
69
+ report: handler.report,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ return entries;
75
+ }
76
+ function formatHookEntry(entry) {
77
+ const matcher = formatMatcher(entry.matcher);
78
+ return `${entry.scope} ${entry.name} ${entry.event} ${matcher} timeout=${entry.timeoutSeconds}s report=${entry.report} :: ${entry.command}`;
79
+ }
80
+ function formatMatcher(matcher) {
81
+ if (!matcher)
82
+ return "matcher=*";
83
+ const parts = [
84
+ matcher.tool ? `tool=${matcher.tool}` : undefined,
85
+ matcher.commandRegex ? `commandRegex=${matcher.commandRegex}` : undefined,
86
+ matcher.pathRegex ? `pathRegex=${matcher.pathRegex}` : undefined,
87
+ matcher.provider ? `provider=${matcher.provider}` : undefined,
88
+ matcher.workspaceMode ? `workspaceMode=${matcher.workspaceMode}` : undefined,
89
+ ].filter((value) => value !== undefined);
90
+ return parts.length > 0 ? parts.join(" ") : "matcher=*";
91
+ }
92
+ function printHooksHelp() {
93
+ console.log([
94
+ "ForgeRelay hooks",
95
+ "",
96
+ "Usage:",
97
+ " forgerelay hooks list [--project <path>]",
98
+ " forgerelay hooks check [--project <path>]",
99
+ ].join("\n"));
100
+ }