@akira-tl/forgerelay 0.1.1 → 0.2.1

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,43 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.1] - 2026-08-09
8
+
9
+ ### Changed
10
+
11
+ - Local console logging now defaults to a compact Loguru-style `pretty` format focused on Agent operations: short timestamps, workspace/session context, tool or Hook action, target, and `ok`/`error` or shell exit status. HTTP request logging is off by default in human mode, while explicit `json` mode keeps the previous request-on and shell-command-off machine defaults.
12
+
13
+ ### Fixed
14
+
15
+ - File and search tools can access and modify files in the operating system temporary directory without making that directory an implicit workspace root; Codex `apply_patch` supports absolute OS-temp paths while workspace patch paths remain relative.
16
+
17
+ ### Security
18
+
19
+ - 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
20
+
21
+ ## [0.2.0] - 2026-08-09
22
+
23
+ ### Added
24
+
25
+ - 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.
26
+ - 用户或 Agent 可写的生命周期 Hook,覆盖 workspace、MCP tool、明确文件变更、managed-worktree close 与本地 subagent 生命周期。
27
+ - 首选的一 Hook 一文件配置:全局 `hooks/<hook-name>.json` 与项目 `.forgerelay/hooks/<hook-name>.json` 自动组合,文件名直接作为 Hook 名并按文件名稳定排序;旧 inline/聚合格式继续兼容。
28
+ - 独立 Hook 的 `event + matcher + command`、bounded timeout 与 `report` 配置;可报告结果进入 Agent 可见返回,阻断失败始终可见。
29
+ - 只读的 `forgerelay hooks list` / `hooks check` CLI,用于查看实际加载规则和在不执行 Hook 的情况下校验全局/项目配置。
30
+ - `BeforeTool` 与 `BeforeWorktreeClose` 阻断语义,以及不会伪装回滚已完成操作的 observational after-events。
31
+ - 通过 `FORGERELAY_HOOK_*` 和 workspace 环境变量提供生命周期上下文,同时避免暴露 native-file credentials 或 subagent prompts。
32
+ - 异步 subagent Hook report 的 session 持久化与 `agents show` 展示。
33
+
34
+ ### Fixed
35
+
36
+ - MCP `initialize` now reports the package version from `package.json` instead of a stale hardcoded `0.1.0` server version.
37
+ - Hook commands on Windows preserve quoted arguments when executed through `cmd.exe`, fixing release-gate and other Hook commands that reference absolute paths.
38
+
39
+ ### Security
40
+
41
+ - 项目 Hook 作为 allowed-root 内项目执行约定自动生效,不需要批准;Hook 文件只能声明生命周期规则,不能扩大 allowed roots、修改认证配置或覆盖全局规则。
42
+ - 无效项目 Hook 配置会作为 Agent 可见 diagnostic 返回,同时保持工具可用;独立目录中的坏文件会被跳过,其他有效 Hook 继续加载,避免 Agent 因写坏单个规则而无法修复。
43
+
7
44
  ## [0.1.1] - 2026-08-09
8
45
 
9
46
  ### Changed
package/README.md CHANGED
@@ -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
@@ -239,6 +273,7 @@ Trusted Publishing setup.
239
273
  ## Documentation
240
274
 
241
275
  - [Setup Guide](docs/setup.md)
276
+ - [Local Debugging and 7677 Acceptance](docs/debugging.md)
242
277
  - [ChatGPT Coding Workflow](docs/chatgpt-coding-workflow.md)
243
278
  - [Configuration Reference](docs/configuration.md)
244
279
  - [Agent Profile Schema](docs/agent-profile-schema.md)
@@ -266,7 +301,17 @@ The original copyright notice remains in [LICENSE](LICENSE). See
266
301
  ```bash
267
302
  npm install --include=dev
268
303
  npm run dev
304
+ npm run debug:accept
269
305
  npm run typecheck
270
306
  npm test
271
307
  npm run build
272
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.
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile }
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
5
  import { TextDecoder } from "node:util";
6
6
  import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff";
7
+ import { AccessDeniedError, resolveCanonicalAllowedPath } from "./roots.js";
7
8
  function patchError(message) {
8
9
  return new Error(`Invalid patch: ${message}`);
9
10
  }
@@ -137,10 +138,24 @@ function isInside(root, path) {
137
138
  const rel = relative(root, path);
138
139
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
139
140
  }
140
- async function resolveConfinedPath(root, input) {
141
- if (!input || input.includes("\0") || isAbsolute(input)) {
141
+ async function resolveConfinedPath(root, input, auxiliaryRoots = []) {
142
+ if (!input || input.includes("\0")) {
142
143
  throw patchError(`path must be relative to the workspace: ${input}`);
143
144
  }
145
+ if (isAbsolute(input)) {
146
+ if (auxiliaryRoots.length === 0) {
147
+ throw patchError(`path must be relative to the workspace: ${input}`);
148
+ }
149
+ try {
150
+ return await resolveCanonicalAllowedPath(input, root, auxiliaryRoots);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof AccessDeniedError) {
154
+ throw patchError(`path is outside allowed auxiliary roots: ${input}`);
155
+ }
156
+ throw error;
157
+ }
158
+ }
144
159
  const rootPath = await realpath(root);
145
160
  const target = resolve(rootPath, input);
146
161
  if (!isInside(rootPath, target)) {
@@ -269,7 +284,7 @@ export async function isSamePatchFile(source, destination, readIdentity = lstat)
269
284
  throw error;
270
285
  }
271
286
  }
272
- export async function applyPatch(root, patch) {
287
+ export async function applyPatch(root, patch, auxiliaryRoots = []) {
273
288
  const actions = parsePatch(patch);
274
289
  const results = [];
275
290
  const patches = [];
@@ -289,14 +304,14 @@ export async function applyPatch(root, patch) {
289
304
  };
290
305
  for (const action of actions) {
291
306
  if (action.kind === "add") {
292
- const absolute = await resolveConfinedPath(root, action.path);
307
+ const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
293
308
  const original = await readStagedOptional(absolute, action.path);
294
309
  staged.set(absolute, { content: action.content, mode: original?.mode });
295
310
  patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content));
296
311
  results.push({ path: action.path, operation: original ? "update" : "add" });
297
312
  continue;
298
313
  }
299
- const absolute = await resolveConfinedPath(root, action.path);
314
+ const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
300
315
  const file = await readStagedRequired(absolute, action.path);
301
316
  if (action.kind === "delete") {
302
317
  staged.set(absolute, null);
@@ -306,7 +321,7 @@ export async function applyPatch(root, patch) {
306
321
  }
307
322
  const updated = applyHunks(action.path, file.content, action.hunks);
308
323
  if (action.moveTo) {
309
- const destination = await resolveConfinedPath(root, action.moveTo);
324
+ const destination = await resolveConfinedPath(root, action.moveTo, auxiliaryRoots);
310
325
  const samePatchFile = await isSamePatchFile(absolute, destination);
311
326
  if (!samePatchFile)
312
327
  await readStagedOptional(destination, action.moveTo);
@@ -5,8 +5,9 @@ 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
- import { logEvent } from "./logger.js";
10
+ import { logEvent, sessionIdPrefix, workspaceLogLabel } from "./logger.js";
10
11
  const ARTIFACT_WRITE_ANNOTATIONS = {
11
12
  readOnlyHint: false,
12
13
  destructiveHint: false,
@@ -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,37 @@ 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, extra) => {
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, {
63
+ workspace: workspaceLogLabel(workspace.root, workspace.id),
64
+ session: sessionIdPrefix(extra?.sessionId),
65
+ }, async () => {
66
+ const downloaded = await downloadIncomingArtifact({
67
+ registry: incomingRegistry,
68
+ workspaceId: workspace.id,
69
+ workspaceRoot: workspace.root,
70
+ maxFileBytes: config.artifactMaxFileBytes,
71
+ file: input.file,
72
+ path: input.path,
73
+ });
74
+ return {
75
+ publicResult: { path: downloaded.path },
76
+ logResult: downloaded,
77
+ };
78
+ }),
58
79
  });
59
- return {
60
- publicResult: { path: downloaded.path },
61
- logResult: downloaded,
62
- };
63
- }));
80
+ });
64
81
  }
65
82
  /**
66
83
  * Stream a trusted native file directly into one already-open workspace.
@@ -151,7 +168,7 @@ export function artifactToolLogFields(input) {
151
168
  path: input.path,
152
169
  };
153
170
  }
154
- async function executeArtifactTool(config, input, operation) {
171
+ async function executeArtifactTool(config, input, logContext, operation) {
155
172
  const startedAt = performance.now();
156
173
  try {
157
174
  const { publicResult, logResult } = await operation();
@@ -159,6 +176,7 @@ async function executeArtifactTool(config, input, operation) {
159
176
  logEvent(config.logging, "info", "artifact_tool_call", {
160
177
  tool: "download_artifact",
161
178
  ...artifactToolLogFields(input),
179
+ ...logContext,
162
180
  path: logResult.path,
163
181
  size: logResult.size,
164
182
  sha256: logResult.sha256,
@@ -173,6 +191,7 @@ async function executeArtifactTool(config, input, operation) {
173
191
  logEvent(config.logging, "warn", "artifact_tool_call", {
174
192
  tool: "download_artifact",
175
193
  ...artifactToolLogFields(input),
194
+ ...logContext,
176
195
  success: false,
177
196
  errorCode: error instanceof ArtifactError ? error.code : "internal_error",
178
197
  durationMs: Math.round(performance.now() - startedAt),
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;
@@ -80,10 +81,10 @@ function parseLogLevel(value) {
80
81
  throw new Error(`Invalid FORGERELAY_LOG_LEVEL: ${value}`);
81
82
  }
82
83
  function parseLogFormat(value) {
83
- if (!value || value === "json")
84
- return "json";
85
- if (value === "pretty")
84
+ if (!value || value === "pretty")
86
85
  return "pretty";
86
+ if (value === "json")
87
+ return "json";
87
88
  throw new Error(`Invalid FORGERELAY_LOG_FORMAT: ${value}`);
88
89
  }
89
90
  function parsePathList(value) {
@@ -109,15 +110,17 @@ function parsePositiveInteger(value, fallback, name, max = Number.MAX_SAFE_INTEG
109
110
  return parsed;
110
111
  }
111
112
  function parseLoggingConfig(env) {
113
+ const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
112
114
  const requests = productEnv(env, "LOG_REQUESTS");
113
115
  const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
116
+ const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
114
117
  return {
115
118
  level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
116
- format: parseLogFormat(productEnv(env, "LOG_FORMAT")),
117
- requests: requests === undefined ? true : parseBoolean(requests),
119
+ format,
120
+ requests: requests === undefined ? format === "json" : parseBoolean(requests),
118
121
  assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
119
122
  toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
120
- shellCommands: parseBoolean(productEnv(env, "LOG_SHELL_COMMANDS")),
123
+ shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
121
124
  trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
122
125
  };
123
126
  }
@@ -214,6 +217,7 @@ export function loadConfig(env = process.env) {
214
217
  : parseBoolean(productEnv(env, "SUBAGENTS")),
215
218
  agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
216
219
  systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
220
+ hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
217
221
  logging: parseLoggingConfig(env),
218
222
  };
219
223
  }
@@ -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
+ }