@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.
@@ -1,8 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { expandHomePath } from "./roots.js";
6
+ import { mergeHookConfigs, parseHookFile, } from "./hooks.js";
6
7
  export function forgerelayConfigDir(env = process.env) {
7
8
  const explicit = env.FORGERELAY_CONFIG_DIR ?? env.DEVSPACE_CONFIG_DIR;
8
9
  if (explicit)
@@ -19,6 +20,12 @@ export function forgerelayConfigPath(env = process.env) {
19
20
  export function forgerelayAuthPath(env = process.env) {
20
21
  return join(forgerelayConfigDir(env), "auth.json");
21
22
  }
23
+ export function forgerelayHooksPath(env = process.env) {
24
+ return join(forgerelayConfigDir(env), "hooks.json");
25
+ }
26
+ export function forgerelayHooksDir(env = process.env) {
27
+ return join(forgerelayConfigDir(env), "hooks");
28
+ }
22
29
  export function forgerelaySkillsDir(env = process.env) {
23
30
  return join(forgerelayConfigDir(env), "skills");
24
31
  }
@@ -29,16 +36,22 @@ export function loadForgeRelayFiles(env = process.env) {
29
36
  const dir = forgerelayConfigDir(env);
30
37
  const configPath = join(dir, "config.json");
31
38
  const authPath = join(dir, "auth.json");
39
+ const hooksPath = join(dir, "hooks.json");
32
40
  const configExists = existsSync(configPath);
33
41
  const authExists = existsSync(authPath);
42
+ const hooksExists = existsSync(hooksPath);
34
43
  return {
35
44
  dir,
36
45
  configPath,
37
46
  authPath,
47
+ hooksPath,
38
48
  configExists,
39
49
  authExists,
50
+ hooksExists,
40
51
  config: configExists ? readJsonFile(configPath) : {},
41
52
  auth: authExists ? readJsonFile(authPath) : {},
53
+ hooks: hooksExists ? readJsonFile(hooksPath) : {},
54
+ hookFiles: readHookFiles(join(dir, "hooks")),
42
55
  usingLegacyDir: dir === resolve(join(homedir(), ".devspace")),
43
56
  };
44
57
  }
@@ -83,6 +96,25 @@ export const loadDevspaceFiles = loadForgeRelayFiles;
83
96
  export const writeDevspaceConfig = writeForgeRelayConfig;
84
97
  export const writeDevspaceAuth = writeForgeRelayAuth;
85
98
  export const ensureDevspaceDefaultSkills = ensureForgeRelayDefaultSkills;
99
+ function readHookFiles(directory) {
100
+ if (!existsSync(directory))
101
+ return {};
102
+ let hooks = {};
103
+ const entries = readdirSync(directory, { withFileTypes: true })
104
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
105
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
106
+ for (const entry of entries) {
107
+ const filePath = join(directory, entry.name);
108
+ try {
109
+ hooks = mergeHookConfigs(hooks, parseHookFile(readJsonFile(filePath), entry.name.slice(0, -5)));
110
+ }
111
+ catch (error) {
112
+ const reason = error instanceof Error ? error.message : String(error);
113
+ throw new Error(`Unable to load hook file ${filePath}: ${reason}`);
114
+ }
115
+ }
116
+ return hooks;
117
+ }
86
118
  function readJsonFile(filePath) {
87
119
  try {
88
120
  return JSON.parse(readFileSync(filePath, "utf8"));
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
- import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
4
+ import { HookRunner } from "./hooks.js";
5
5
  import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
6
6
  import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
7
7
  import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./skills.js";
@@ -11,9 +11,11 @@ export class WorkspaceRegistry {
11
11
  store;
12
12
  workspaces = new Map();
13
13
  pendingOpens = new Map();
14
+ hooks;
14
15
  constructor(config, store) {
15
16
  this.config = config;
16
17
  this.store = store;
18
+ this.hooks = new HookRunner(config.hooks, config.logging);
17
19
  }
18
20
  async openWorkspace(input, openOptions = {}) {
19
21
  const workspaceInput = typeof input === "string" ? { path: input } : input;
@@ -71,14 +73,41 @@ export class WorkspaceRegistry {
71
73
  detached: false,
72
74
  managed: true,
73
75
  };
76
+ const hookReports = await this.hooks.run("BeforeWorktreeClose", {
77
+ workspaceId: workspace.id,
78
+ workspaceRoot: workspace.root,
79
+ workspaceMode: workspace.mode,
80
+ sourceRoot: workspace.sourceRoot,
81
+ payload: {
82
+ commitMessage,
83
+ branch: managedWorktree.branch,
84
+ targetBranch: managedWorktree.targetBranch,
85
+ },
86
+ });
74
87
  const result = await closeManagedWorktree({
75
88
  worktree: managedWorktree,
76
89
  commitMessage,
77
90
  config: this.config,
78
91
  });
92
+ hookReports.push(...await this.hooks.run("AfterWorktreeClose", {
93
+ workspaceId: workspace.id,
94
+ workspaceRoot: workspace.root,
95
+ workspaceMode: workspace.mode,
96
+ sourceRoot: workspace.sourceRoot,
97
+ cwd: result.sourceRoot,
98
+ payload: {
99
+ commitMessage,
100
+ branch: result.branch,
101
+ targetBranch: result.targetBranch,
102
+ commitSha: result.commitSha,
103
+ mergedSha: result.mergedSha,
104
+ committed: result.committed,
105
+ cleanupWarning: result.cleanupWarning,
106
+ },
107
+ }));
79
108
  this.store?.setSessionStatus(workspace.id, "closed");
80
109
  this.workspaces.delete(workspace.id);
81
- return result;
110
+ return { ...result, hookReports };
82
111
  }
83
112
  async openReusableCheckout(path, conversationScopeId) {
84
113
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
@@ -235,6 +264,7 @@ export class WorkspaceRegistry {
235
264
  workspace,
236
265
  agentsFiles,
237
266
  availableAgentsFiles,
267
+ hookReports: [],
238
268
  workspaceReused: true,
239
269
  includeBootstrapContext: true,
240
270
  };
@@ -353,12 +383,25 @@ export class WorkspaceRegistry {
353
383
  managed: workspace.worktree?.managed,
354
384
  });
355
385
  this.workspaces.set(workspace.id, workspace);
386
+ const hookReports = await this.hooks.run("WorkspaceOpen", {
387
+ workspaceId: workspace.id,
388
+ workspaceRoot: workspace.root,
389
+ workspaceMode: workspace.mode,
390
+ sourceRoot: workspace.sourceRoot,
391
+ payload: {
392
+ mode: workspace.mode,
393
+ sourceRoot: workspace.sourceRoot,
394
+ branch: workspace.worktree?.branch,
395
+ targetBranch: workspace.worktree?.targetBranch,
396
+ },
397
+ });
356
398
  const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
357
399
  const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
358
400
  return {
359
401
  workspace,
360
402
  agentsFiles,
361
403
  availableAgentsFiles,
404
+ hookReports,
362
405
  workspaceReused: false,
363
406
  includeBootstrapContext: true,
364
407
  };
@@ -381,21 +424,34 @@ export class WorkspaceRegistry {
381
424
  return assertAllowedPath(root, this.config.allowedRoots);
382
425
  }
383
426
  async loadInitialAgentsFiles(root) {
384
- const agentDir = resolve(this.config.agentDir);
385
427
  const resolvedRoot = (await tryRealpath(root)) ?? root;
386
- const resolvedAgentDir = (await tryRealpath(agentDir)) ?? agentDir;
428
+ const systemInstructionsPath = resolve(this.config.systemInstructionsPath);
387
429
  const loadedFiles = [];
388
- for (const file of loadProjectContextFiles({ cwd: root, agentDir })) {
389
- const path = resolve(file.path);
390
- if (!isInitialAgentsFilePath(path, root, agentDir))
391
- continue;
392
- const content = await readResolvedContextFile(path, file.content, resolvedRoot, resolvedAgentDir);
430
+ const loadedRealPaths = new Set();
431
+ const systemInstructions = await readSystemInstructions(systemInstructionsPath);
432
+ const systemInstructionsRealPath = await tryRealpath(systemInstructionsPath);
433
+ if (systemInstructions !== undefined) {
434
+ loadedFiles.push({
435
+ path: systemInstructionsPath,
436
+ content: systemInstructions,
437
+ });
438
+ if (systemInstructionsRealPath)
439
+ loadedRealPaths.add(systemInstructionsRealPath);
440
+ }
441
+ for (const fileName of CONTEXT_FILE_NAMES) {
442
+ const path = join(root, fileName);
443
+ const content = await readResolvedProjectContextFile(path, resolvedRoot);
393
444
  if (content === undefined)
394
445
  continue;
446
+ const realPath = await tryRealpath(path);
447
+ if (realPath && loadedRealPaths.has(realPath))
448
+ continue;
395
449
  loadedFiles.push({
396
450
  path,
397
451
  content,
398
452
  });
453
+ if (realPath)
454
+ loadedRealPaths.add(realPath);
399
455
  }
400
456
  return loadedFiles;
401
457
  }
@@ -408,7 +464,10 @@ export class WorkspaceRegistry {
408
464
  loadedRealPaths.add(realPath);
409
465
  }
410
466
  const discovered = [];
467
+ const agentDir = resolve(this.config.agentDir);
411
468
  await walkWorkspace(root, async (path, entry) => {
469
+ if (isPathInsideRoot(path, agentDir))
470
+ return;
412
471
  if (!entry.isFile())
413
472
  return;
414
473
  if (!CONTEXT_FILE_NAMES.has(entry.name))
@@ -480,20 +539,32 @@ export function formatAgentsPath(path, workspaceRoot) {
480
539
  }
481
540
  return relationship.split(sep).join("/");
482
541
  }
483
- function isInitialAgentsFilePath(path, root, agentDir) {
484
- if (isPathInsideRoot(path, agentDir))
485
- return true;
542
+ function isProjectRootInstructionPath(path, root) {
486
543
  return isPathInsideRoot(path, root) && dirname(path) === root;
487
544
  }
488
- async function readResolvedContextFile(path, fallbackContent, root, agentDir) {
545
+ async function readSystemInstructions(path) {
546
+ try {
547
+ return await readFile(path, "utf8");
548
+ }
549
+ catch (error) {
550
+ if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
551
+ return undefined;
552
+ }
553
+ throw error;
554
+ }
555
+ }
556
+ async function readResolvedProjectContextFile(path, root) {
489
557
  try {
490
558
  const resolvedPath = await realpath(path);
491
- if (!isInitialAgentsFilePath(resolvedPath, root, agentDir))
559
+ if (!isProjectRootInstructionPath(resolvedPath, root))
492
560
  return undefined;
493
561
  return await readFile(resolvedPath, "utf8");
494
562
  }
495
- catch {
496
- return fallbackContent;
563
+ catch (error) {
564
+ if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
565
+ return undefined;
566
+ }
567
+ throw error;
497
568
  }
498
569
  }
499
570
  async function tryRealpath(path) {
@@ -87,10 +87,14 @@ placed into a merge-conflict state.
87
87
  Legacy `devspace/*` managed branches remain closable when they are already stored
88
88
  in workspace metadata; only new managed branches use `forgerelay/*`.
89
89
 
90
- ## Project instructions
90
+ ## Instructions
91
91
 
92
- When a workspace opens, ForgeRelay loads root-level instruction files when they
93
- exist:
92
+ When a workspace opens, ForgeRelay first loads exactly one global system-instructions
93
+ file. The default is `~/.agents/AGENTS.md`; configure a different single path with
94
+ `FORGERELAY_SYSTEM_INSTRUCTIONS_PATH`. Symbolic links are followed so this entry can
95
+ point at a canonical source elsewhere on disk.
96
+
97
+ ForgeRelay then loads root-level project instruction files when they exist:
94
98
 
95
99
  ```text
96
100
  AGENTS.md
@@ -99,8 +103,10 @@ CLAUDE.md
99
103
  CLAUDE.MD
100
104
  ```
101
105
 
102
- Nested instruction files are returned as available paths rather than all being
103
- injected eagerly. Read the relevant nested file before working under that path.
106
+ Nested project instruction files are returned as available paths rather than all
107
+ being injected eagerly. Read the relevant nested file before working under that path.
108
+ `FORGERELAY_AGENT_DIR` is not an instruction source; it remains only a compatibility
109
+ skill-discovery path.
104
110
 
105
111
  ## Agent Skills
106
112
 
@@ -136,6 +136,143 @@ programs when the optional `node-pty` dependency is available.
136
136
  | `changes` | Attach UI to `open_workspace` and aggregate `show_changes`. |
137
137
  | `off` | Disable widget UI. |
138
138
 
139
+ ## Lifecycle hooks
140
+
141
+ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;命中后 ForgeRelay 直接执行,不再增加批准步骤。
142
+
143
+ 首选格式是 **一个 Hook 一个 JSON 文件**。全局 Hook 放在当前 ForgeRelay 配置目录的 `hooks/` 下,新安装通常是:
144
+
145
+ ```text
146
+ ~/.forgerelay/hooks/<hook-name>.json
147
+ ```
148
+
149
+ 项目 Hook 放在工作区根目录:
150
+
151
+ ```text
152
+ <workspace>/.forgerelay/hooks/<hook-name>.json
153
+ ```
154
+
155
+ 文件名去掉 `.json` 后就是 Hook 名,也是日志和 Agent-visible report 中显示的名称。例如 `release-tag-local-ci.json` 会显示为 `release-tag-local-ci`。目录内按文件名字典序执行;需要显式排序时可以使用 `10-release-verify.json`、`20-package-inspection.json` 这样的前缀。ForgeRelay 只读取普通 `*.json` 文件,所以临时停用某条 Hook 时可以把扩展名改掉。
156
+
157
+ 全局 Hook 在 server 启动时读取,修改后需要重启 ForgeRelay;项目目录在每次事件时重新读取,所以 Agent 修改项目 Hook 后不需要重启。全局规则先执行,项目规则随后执行,两边都只做追加,不互相覆盖。
158
+
159
+ 每个独立 Hook 文件只描述一条规则:
160
+
161
+ ```json
162
+ {
163
+ "event": "BeforeTool",
164
+ "matcher": {
165
+ "tool": "bash",
166
+ "commandRegex": "^git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+$"
167
+ },
168
+ "command": "npm run release:verify",
169
+ "timeoutSeconds": 300,
170
+ "report": true
171
+ }
172
+ ```
173
+
174
+ 这个例子可以保存为 `.forgerelay/hooks/release-tag-local-ci.json`。Agent 通过 ForgeRelay `bash` 请求推送稳定版本 tag 时,Hook 先跑本地发布检查;成功后才执行原始 `git push`,失败则直接阻断并把 `release-tag-local-ci` 的失败报告返回给 Agent。
175
+
176
+ 独立 Hook 文件支持这些顶层字段:
177
+
178
+ | 字段 | 含义 |
179
+ | --- | --- |
180
+ | `event` | 必填,九个 Hook event 之一。 |
181
+ | `matcher` | 可选,只在匹配当前生命周期上下文时执行。 |
182
+ | `command` | 必填,本地 shell 命令。 |
183
+ | `timeoutSeconds` | 默认 `30`,范围 `1` 到 `300`。 |
184
+ | `report` | 默认 `true`。为 `false` 时成功结果不主动出现在 Agent 可见报告中;blocking 失败始终可见。 |
185
+
186
+ 独立文件不写 `name`:文件名就是唯一的 Hook 名。一个逻辑 Hook 如果需要多个独立步骤,拆成多个文件;这样可以单独启停、重命名、排序和审查每一步。
187
+
188
+ 为兼容已有配置,ForgeRelay 仍接受旧的 `config.json -> hooks`、全局 `hooks.json` 和项目 `.forgerelay/hooks.json` 聚合格式。执行顺序是旧配置在前、`hooks/*.json` 独立文件在后。新配置应优先使用独立文件。
189
+
190
+ 若某个项目 Hook 文件 JSON 或 schema 无效,ForgeRelay 会返回 `Project hooks config` diagnostic,同时继续加载其他有效项目 Hook,并保持 workspace/tool 可用,让 Agent 可以直接修复出错文件。
191
+
192
+ ### 检查 Hook 配置
193
+
194
+ CLI 可以只读检查规则,不会启动 MCP server,也不会执行 Hook:
195
+
196
+ ```bash
197
+ forgerelay hooks list
198
+ forgerelay hooks check
199
+ forgerelay hooks list --project /path/to/project
200
+ forgerelay hooks check --project /path/to/project
201
+ ```
202
+
203
+ 不传 `--project` 时使用当前目录。`list` 展示实际加载的全局与项目规则,包括 event、matcher、timeout、`report` 和 command;`check` 只做解析与 schema 校验,成功时输出全局/项目 Hook 数量,发现坏的全局或项目文件时返回非零状态。
204
+
205
+ `matcher` 当前支持:
206
+
207
+ | 字段 | 匹配方式 |
208
+ | --- | --- |
209
+ | `tool` | 精确匹配 MCP tool 名称。 |
210
+ | `commandRegex` | 对 tool payload 中的 `command` 做 JavaScript 正则匹配。 |
211
+ | `pathRegex` | 对 payload 中的 `path` 或 `paths` 做正则匹配。 |
212
+ | `provider` | 精确匹配 subagent provider。 |
213
+ | `workspaceMode` | `checkout` 或 `worktree`。 |
214
+
215
+ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令内部后续启动的子进程。例如 `bash` 参数本身是 `git push origin v0.2.0` 时可以命中;若参数只是 `./release.sh`,而脚本内部再执行 `git push`,ForgeRelay 不会把内部子进程重新解释成新的 Hook 事件。
216
+
217
+ 旧聚合格式里的 `matcher -> handlers` 与 handler `name` 继续按原语义工作,只作为兼容入口保留。
218
+
219
+ ### 事件
220
+
221
+ | Event | 语义 |
222
+ | --- | --- |
223
+ | `WorkspaceOpen` | 新 workspace session 创建后触发;复用已有 workspace 不重复触发。 |
224
+ | `BeforeTool` | workspace-scoped MCP tool 执行前触发;失败或超时会阻断原操作。`open_workspace` 因执行前还没有 workspace,不走该事件。 |
225
+ | `AfterTool` | tool 成功后触发。 |
226
+ | `AfterToolFailure` | tool 失败或被 `BeforeTool` 拒绝后触发。 |
227
+ | `AfterFileChange` | `write`、`edit`、`apply_patch`、native artifact 等明确文件变更成功后触发;不会推断 shell 的文件副作用。 |
228
+ | `BeforeWorktreeClose` | worktree commit、fast-forward、cleanup 前触发;失败会保留 worktree 并阻断 close。 |
229
+ | `AfterWorktreeClose` | managed worktree 成功关闭后触发;此时从 source checkout 运行。 |
230
+ | `SubagentStart` | 本地 subagent worker 进入执行时触发。 |
231
+ | `SubagentStop` | subagent 完成或进入 error 状态时触发。 |
232
+
233
+ `BeforeTool` 与 `BeforeWorktreeClose` 是 blocking 事件。其他事件是 observational:失败会被记录并报告,但不会回滚已经完成的文件、Git、进程或网络副作用。Blocking 同样不是事务;Hook 命令自己已经产生的副作用不会因 exit code 非零而撤销。
234
+
235
+ ### Agent 可见报告
236
+
237
+ `report:true` 的执行结果会进入模型可见 tool result,例如:
238
+
239
+ ```text
240
+ Hook results:
241
+ ✓ release-tag-local-ci (BeforeTool, project) passed in 38124ms
242
+ ```
243
+
244
+ 阻断失败会明确显示 `failed`。ForgeRelay 的 server instructions 要求 Agent 在出现 Hook results 时,向用户说明有意义的 Hook 是否通过或阻断了操作。异步 subagent 的 `SubagentStart` / `SubagentStop` 报告会随 session 持久化,并由 `forgerelay agents show` 展示。
245
+
246
+ ### Hook 环境
247
+
248
+ Hook 命令继承 ForgeRelay 进程环境,并额外获得:
249
+
250
+ | Variable | 含义 |
251
+ | --- | --- |
252
+ | `FORGERELAY_HOOK_EVENT` | 当前事件名。 |
253
+ | `FORGERELAY_HOOK_PAYLOAD` | 事件相关 metadata 的 JSON。 |
254
+ | `FORGERELAY_WORKSPACE_ROOT` | 当前 workspace root。 |
255
+ | `FORGERELAY_WORKSPACE_ID` | 已知时提供 workspace ID;直接 CLI subagent 可能没有。 |
256
+ | `FORGERELAY_WORKSPACE_MODE` | 已知时为 `checkout` 或 `worktree`。 |
257
+ | `FORGERELAY_SOURCE_ROOT` | managed worktree 场景中的 source checkout。 |
258
+ | `FORGERELAY_TOOL_NAME` | tool 生命周期事件中的 MCP tool 名称。 |
259
+
260
+ Payload 用于策略和自动化,不包含文件正文、native-file credentials 或 subagent prompt。Shell Hook 会看到请求本身的 command metadata,因此 Hook 自己的日志仍应按可能含敏感参数处理。
261
+
262
+ Hook 命令与 ForgeRelay 使用同一个本地用户权限。项目 `.forgerelay/hooks/*.json` 是可执行项目约定;允许某个 root 后,应把该 root 中的项目 Hook 视为本地开发环境的一部分。详见 [Security Model](security.md)。
263
+
264
+ ## System instructions
265
+
266
+ ForgeRelay loads exactly one global system-instructions file. The default is
267
+ `~/.agents/AGENTS.md`. Set `FORGERELAY_SYSTEM_INSTRUCTIONS_PATH` or the
268
+ `systemInstructionsPath` config key to point at a different single file.
269
+ Arrays or empty values are not accepted. Symbolic links are followed, so the
270
+ runtime entry may point at a canonical source elsewhere on disk.
271
+
272
+ Project-root `AGENTS.md` / `CLAUDE.md` files remain project context and are
273
+ loaded separately. `FORGERELAY_AGENT_DIR` does not select a global instruction
274
+ file; it remains a compatibility path for Agent Skills.
275
+
139
276
  ## Skills and subagents
140
277
 
141
278
  | Variable | Purpose |
@@ -0,0 +1,126 @@
1
+ # Local Debugging
2
+
3
+ ForgeRelay development uses a dedicated loopback server on port `7677`. The
4
+ normal product default remains `7676`; `7677` is reserved for this repository's
5
+ local debug and acceptance workflow so development does not collide with a
6
+ normally installed ForgeRelay instance.
7
+
8
+ ## Start the debug server
9
+
10
+ ```bash
11
+ npm run dev
12
+ ```
13
+
14
+ `npm run debug:serve` is an explicit alias for the same command.
15
+
16
+ The debug launcher uses [`scripts/debug/config.json`](../scripts/debug/config.json)
17
+ and prints the generated Owner password when it starts. It binds only to:
18
+
19
+ ```text
20
+ http://127.0.0.1:7677
21
+ ```
22
+
23
+ Useful endpoints are:
24
+
25
+ ```text
26
+ http://127.0.0.1:7677/healthz
27
+ http://127.0.0.1:7677/.well-known/oauth-protected-resource/mcp
28
+ http://127.0.0.1:7677/.well-known/oauth-authorization-server
29
+ http://127.0.0.1:7677/mcp
30
+ ```
31
+
32
+ The debug server watches `src/` and restarts after source changes. One generated
33
+ Owner password is kept for the lifetime of the launcher so restarts do not force
34
+ a new debug credential.
35
+
36
+ To provide a stable local-only Owner password instead of the generated one:
37
+
38
+ ```bash
39
+ FORGERELAY_DEBUG_OWNER_TOKEN="local-debug-password-at-least-16-chars" npm run dev
40
+ ```
41
+
42
+ Do not use that variable for a publicly reachable ForgeRelay deployment.
43
+
44
+ ## Run the end-to-end acceptance
45
+
46
+ ```bash
47
+ npm run debug:accept
48
+ ```
49
+
50
+ The acceptance runner starts its own ForgeRelay process on `127.0.0.1:7677`,
51
+ sends real HTTP requests with `curl`, and shuts the server down when it finishes.
52
+ It refuses to start when port `7677` is already occupied so it cannot
53
+ accidentally validate an older debug process.
54
+
55
+ The acceptance checks:
56
+
57
+ 1. `/healthz`;
58
+ 2. OAuth protected-resource and authorization-server discovery;
59
+ 3. unauthenticated `/mcp` rejection;
60
+ 4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
61
+ 5. MCP `initialize`, including package/server version consistency;
62
+ 6. `tools/list` for the full debug tool surface;
63
+ 7. a real checkout workspace with `write`, `read`, `bash`, and a deliberate failed `edit`;
64
+ 8. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
65
+ 9. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
66
+ 10. deterministic local subagent error path,不联系任何模型 provider;
67
+ 11. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
68
+
69
+ `curl` must be available on `PATH` for this acceptance command. Node and Git are
70
+ already normal ForgeRelay development prerequisites.
71
+
72
+ ## Debug configuration
73
+
74
+ The checked-in debug configuration is:
75
+
76
+ ```text
77
+ scripts/debug/config.json
78
+ ```
79
+
80
+ It deliberately contains no Owner password. Runtime secrets are generated by
81
+ the launcher and passed through the environment.
82
+
83
+ The config enables all Hooks v1 events with the local recorder at:
84
+
85
+ ```text
86
+ scripts/debug/hook-recorder.mjs
87
+ ```
88
+
89
+ The recorder keeps only useful lifecycle metadata such as event, workspace,
90
+ tool, paths, branch, status, and subagent identity. It does not copy arbitrary
91
+ hook payloads, shell commands, prompts, OAuth credentials, or native-file
92
+ credentials into the debug log.
93
+
94
+ ## Generated state
95
+
96
+ All debug-only runtime state is written below:
97
+
98
+ ```text
99
+ .forgerelay-debug/
100
+ ```
101
+
102
+ That directory is gitignored. Typical contents include:
103
+
104
+ ```text
105
+ .forgerelay-debug/state/
106
+ .forgerelay-debug/worktrees/
107
+ .forgerelay-debug/hooks.jsonl
108
+ .forgerelay-debug/acceptance/
109
+ ```
110
+
111
+ Acceptance runner 每次都会重建自己的 `acceptance/` 子目录。除了临时 checkout/worktree,它还会建立一个本地 bare Git remote,用来验证 release tag Hook 的真实 `git push` 顺序;不会访问或修改 ForgeRelay 的远程仓库,也不会编辑 tracked project files。
112
+
113
+ ## Scripts
114
+
115
+ The debug workflow is intentionally explicit rather than hidden in ad-hoc shell
116
+ commands:
117
+
118
+ - `scripts/debug/config.json` — loopback `7677` configuration and hook setup;
119
+ - `scripts/debug/runtime.mjs` — shared paths, generated credentials, and deterministic debug environment;
120
+ - `scripts/debug/serve.mjs` — watched local debug server launcher;
121
+ - `scripts/debug/accept.mjs` — real HTTP/OAuth/MCP acceptance runner;
122
+ - `scripts/debug/hook-recorder.mjs` — sanitized JSONL lifecycle recorder.
123
+
124
+ When a bug only appears through the real HTTP transport, add the reproduction to
125
+ `debug:accept` (or a focused test derived from it) rather than relying on a
126
+ one-off local command that cannot be repeated later.
package/docs/roadmap.md CHANGED
@@ -60,32 +60,27 @@ The initial independent release establishes:
60
60
 
61
61
  ## 0.2 — Hooks v1
62
62
 
63
- Hooks are the next priority because they provide a general policy extension point
64
- without hardcoding project-specific commands into Git/workspace logic.
63
+ Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生命周期规则层,而不是复制完整的 Agent 权限或插件系统。
65
64
 
66
- Initial events:
65
+ 当前契约包括:
67
66
 
68
- - `WorkspaceOpen`
69
- - `BeforeTool`
70
- - `AfterTool`
71
- - `AfterToolFailure`
72
- - `AfterFileChange`
73
- - `BeforeWorktreeClose`
74
- - `AfterWorktreeClose`
75
- - `SubagentStart`
76
- - `SubagentStop`
67
+ - 全局 `hooks/<hook-name>.json` 与项目 `.forgerelay/hooks/<hook-name>.json` 自动组合;
68
+ - 一个独立文件就是一个 Hook,文件名就是 Hook 名,目录内按文件名稳定排序;
69
+ - `event + matcher + command` 规则,以及 timeout 与 `report`;
70
+ - 旧 inline/聚合 Hook 配置继续兼容;
71
+ - `BeforeTool` / `BeforeWorktreeClose` 阻断语义;
72
+ - observational after-events;
73
+ - Agent 可见 Hook report;
74
+ - `forgerelay hooks list` / `hooks check` 只读检查入口;
75
+ - `WorkspaceOpen`、tool、文件变更、worktree 与 subagent 生命周期;
76
+ - 项目 Hook 配置损坏时可见且可修复的 diagnostic;
77
+ - 7677 真实网络验收中的 release-tag-push 本地验证场景。
77
78
 
78
- Initial handler type:
79
+ 事件仍保持九个:`WorkspaceOpen`、`BeforeTool`、`AfterTool`、`AfterToolFailure`、`AfterFileChange`、`BeforeWorktreeClose`、`AfterWorktreeClose`、`SubagentStart`、`SubagentStop`。
79
80
 
80
- - local command
81
+ 典型用法是在 `BeforeTool` 中匹配稳定版本 tag 的 `git push`,先执行项目定义的本地 CI;成功后继续 push,失败时让 Agent 获得报告并修复。`BeforeWorktreeClose` 则适合在 managed branch 集成前执行测试、类型检查或其他项目验证。
81
82
 
82
- The first implementation should define a deep hook engine contract and keep MCP
83
- tool handlers thin. HTTP/prompt/agent-style handlers can be evaluated later if
84
- there is a concrete need.
85
-
86
- `BeforeWorktreeClose` should become the natural place for user-configured test,
87
- typecheck, formatting, or security verification before a managed branch is
88
- integrated.
83
+ 0.2 不引入审批 UI、HTTP/prompt/agent handler、Git 字符串解析器或插件注册表。只有出现真实需求时再扩展 handler 类型。
89
84
 
90
85
  ## 0.3 — LSP code intelligence v1
91
86
 
package/docs/security.md CHANGED
@@ -99,6 +99,20 @@ The security model is therefore based on:
99
99
 
100
100
  Do not describe ForgeRelay as a sandboxed coding environment.
101
101
 
102
+ ## Lifecycle hooks
103
+
104
+ Hook command 是本地代码执行,使用与 ForgeRelay 相同的操作系统用户权限并继承进程环境。
105
+
106
+ Hooks v1 有两个自动作用域:当前 ForgeRelay 配置目录中的 `hooks/<hook-name>.json` 全局规则,以及 workspace 根目录的 `.forgerelay/hooks/<hook-name>.json` 项目规则。项目规则不需要额外批准;打开允许根目录中的项目时,ForgeRelay 会把这些 Hook 当作该开发环境的执行约定直接使用,`WorkspaceOpen` 也可以立即触发命令。因此 allowed roots 不只是文件访问边界,也界定了你愿意让 ForgeRelay 操作的本地项目环境。
107
+
108
+ 每个独立 Hook 文件只声明一个 event、可选 matcher 和一个 command,以及 timeout/report。文件名只决定 Hook 名和排序,不能扩大 allowed roots、修改 OAuth 配置或删除全局规则。全局与项目规则采用组合关系。若某个项目 Hook 文件损坏,ForgeRelay 返回可见 diagnostic、跳过该无效文件并继续加载其他有效 Hook,同时保持工具可用,便于 Agent 修复。旧聚合格式仍兼容。
109
+
110
+ `BeforeTool` 和 `BeforeWorktreeClose` 是阻断点:命中的 handler 失败或超时后,待执行操作不会继续。其余事件用于观察已发生的生命周期结果,失败不会回滚已经完成的工作。`report:false` 可以隐藏成功的高频报告,但不能隐藏阻断失败。
111
+
112
+ Hook payload 刻意不携带文件正文、native-file credentials 或 subagent prompt。Shell tool 的 command metadata 仍可能包含敏感参数,因此 Hook 脚本自己的日志也应按敏感输入处理。
113
+
114
+ 详见 [Configuration Reference](configuration.md#lifecycle-hooks)。
115
+
102
116
  ## Git and managed worktrees
103
117
 
104
118
  Managed worktrees are branch-backed and visible in the source repository.