@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 +36 -0
- package/README.md +57 -10
- package/dist/artifact-tools.js +28 -14
- package/dist/cli.js +46 -3
- package/dist/config.js +14 -0
- package/dist/db/migrations.js +8 -0
- package/dist/db/schema.js +1 -0
- package/dist/hook-cli.js +100 -0
- package/dist/hooks.js +542 -0
- package/dist/local-agent-store.js +14 -1
- package/dist/mcp/server-instructions.js +2 -1
- package/dist/process-platform.js +1 -0
- package/dist/server.js +542 -415
- package/dist/user-config.js +33 -1
- package/dist/workspaces.js +87 -16
- package/docs/chatgpt-coding-workflow.md +11 -5
- package/docs/configuration.md +137 -0
- package/docs/debugging.md +126 -0
- package/docs/roadmap.md +16 -21
- package/docs/security.md +14 -0
- package/docs/versioning.md +22 -15
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +615 -0
- package/scripts/debug/config.json +40 -0
- package/scripts/debug/hook-recorder.mjs +35 -0
- package/scripts/debug/runtime.mjs +47 -0
- package/scripts/debug/serve.mjs +37 -0
- package/scripts/dev-server.mjs +1 -1
package/dist/user-config.js
CHANGED
|
@@ -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"));
|
package/dist/workspaces.js
CHANGED
|
@@ -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 {
|
|
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
|
|
428
|
+
const systemInstructionsPath = resolve(this.config.systemInstructionsPath);
|
|
387
429
|
const loadedFiles = [];
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
559
|
+
if (!isProjectRootInstructionPath(resolvedPath, root))
|
|
492
560
|
return undefined;
|
|
493
561
|
return await readFile(resolvedPath, "utf8");
|
|
494
562
|
}
|
|
495
|
-
catch {
|
|
496
|
-
|
|
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
|
-
##
|
|
90
|
+
## Instructions
|
|
91
91
|
|
|
92
|
-
When a workspace opens, ForgeRelay loads
|
|
93
|
-
|
|
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
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
64
|
-
without hardcoding project-specific commands into Git/workspace logic.
|
|
63
|
+
Hooks v1 的目标是给用户和 Agent 一个很小、自动、可组合的生命周期规则层,而不是复制完整的 Agent 权限或插件系统。
|
|
65
64
|
|
|
66
|
-
|
|
65
|
+
当前契约包括:
|
|
67
66
|
|
|
68
|
-
- `
|
|
69
|
-
-
|
|
70
|
-
- `
|
|
71
|
-
-
|
|
72
|
-
- `
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
- `
|
|
76
|
-
- `
|
|
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
|
-
|
|
79
|
+
事件仍保持九个:`WorkspaceOpen`、`BeforeTool`、`AfterTool`、`AfterToolFailure`、`AfterFileChange`、`BeforeWorktreeClose`、`AfterWorktreeClose`、`SubagentStart`、`SubagentStop`。
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
典型用法是在 `BeforeTool` 中匹配稳定版本 tag 的 `git push`,先执行项目定义的本地 CI;成功后继续 push,失败时让 Agent 获得报告并修复。`BeforeWorktreeClose` 则适合在 managed branch 集成前执行测试、类型检查或其他项目验证。
|
|
81
82
|
|
|
82
|
-
|
|
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.
|