@agent_forge/forge-dsh 1.81.0 → 1.83.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/README.md +84 -27
- package/lib/bundled.js +70 -0
- package/lib/decisions.js +151 -10
- package/lib/index.js +257 -57
- package/lib/latest.js +90 -0
- package/lib/messages.js +110 -0
- package/lib/runner.js +38 -7
- package/lib/skills.js +38 -0
- package/lib/tools.js +46 -0
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -22,12 +22,16 @@ the forge binary — the plugin itself has **zero runtime dependencies**.
|
|
|
22
22
|
| `tools/pre-execute` | `PreToolUse` | `{kind:'deny', reason}` |
|
|
23
23
|
| `tools/post-execute` | `PostToolUse` | `{kind:'block', feedback}` (error result) |
|
|
24
24
|
| `agent/pre-step` | `UserPromptSubmit` | `{kind:'reject'}` |
|
|
25
|
-
| `agent/session-start` | `SessionStart`¹ | context
|
|
25
|
+
| `agent/session-start` | `SessionStart`¹ | context queued, delivered with the next `agent/pre-step`² |
|
|
26
26
|
| `agent/turn-stopping` | `Stop` | `agent.steer(reason)` → another step |
|
|
27
27
|
|
|
28
28
|
¹ `source:'compact'` additionally fires forge's `PostCompact` group — the preview
|
|
29
29
|
builds expose no dedicated compaction point.
|
|
30
30
|
|
|
31
|
+
² `agent.inject()` at session start was observed to drop the contexts silently, so they ride the
|
|
32
|
+
first pre-step instead; that pre-step waits up to `startWaitMs` (default 1500) for the detached
|
|
33
|
+
session-start run, and a rejected step keeps the queue for the next one.
|
|
34
|
+
|
|
31
35
|
DSH tool names map onto the Claude Code names forge dispatches on
|
|
32
36
|
(`write/edit/str_replace_editor→Write/Edit`, `bash/pwsh→Bash`, `read/read_image→Read`,
|
|
33
37
|
`skill→Skill`); unmapped tools pass ungated. The wired hook roster mirrors
|
|
@@ -43,35 +47,43 @@ exposes the corresponding event points (the preview builds have neither).
|
|
|
43
47
|
|
|
44
48
|
## Requirements
|
|
45
49
|
|
|
46
|
-
- The `forge` CLI
|
|
47
|
-
|
|
50
|
+
- The `forge` CLI. The plugin depends on `@agent_forge/forge` (pinned to the plugin's
|
|
51
|
+
own version), so installing it through DSH installs forge and uninstalling removes
|
|
52
|
+
it — nothing to put on `PATH`. The bundled binary is not on `PATH`, so at session
|
|
53
|
+
start the agent is told its absolute path to run `forge …` commands with. Override
|
|
54
|
+
with `forgeBin`, or `FORGE_BIN`, to use a different forge. The project still needs
|
|
55
|
+
`forge init` for task gates to have state to enforce.
|
|
48
56
|
- DeepSeek Harness `0.1.0-rc.7` – `0.2.1-rc.2`, Node.js ≥ 18. The bridge contract is
|
|
49
57
|
pinned to `0.1.0-rc.7` ([`contract.json`](./contract.json)); `0.2.1-rc.2` is the
|
|
50
58
|
desktop build this plugin runs on (pre-tool interception verified live there; a
|
|
51
59
|
full five-event regression across the range is still pending).
|
|
52
|
-
- Windows works out of the box:
|
|
53
|
-
|
|
54
|
-
kills tear down the whole child tree via `taskkill /T`.
|
|
60
|
+
- Windows works out of the box: the bundled binary is `forge.exe`, and a `forge.cmd`
|
|
61
|
+
shim from a global npm install is spawned through `cmd.exe` (a bare spawn cannot
|
|
62
|
+
execute it); timeout kills tear down the whole child tree via `taskkill /T`.
|
|
55
63
|
|
|
56
64
|
## Install
|
|
57
65
|
|
|
66
|
+
`<profile>` is the dsh profile that is actually running. **The desktop app runs the
|
|
67
|
+
`desktop` profile** — installing into any other profile (e.g. `web`) succeeds but the
|
|
68
|
+
plugin never loads in the app. List your profiles under `~/.dsh/profiles/`.
|
|
69
|
+
|
|
58
70
|
```sh
|
|
59
71
|
# from npm
|
|
60
|
-
dsh plugin --profile
|
|
72
|
+
dsh plugin --profile <profile> add @agent_forge/forge-dsh
|
|
61
73
|
|
|
62
74
|
# or straight from the Forge repo (subdirectory install)
|
|
63
|
-
dsh plugin --profile
|
|
75
|
+
dsh plugin --profile <profile> add "github:MjxUpUp/Forge#main&path:/plugins/forge-dsh"
|
|
64
76
|
|
|
65
77
|
# local development (run from this directory; `link:.` works on Windows too)
|
|
66
|
-
dsh plugin --profile
|
|
78
|
+
dsh plugin --profile <profile> add "link:."
|
|
67
79
|
```
|
|
68
80
|
|
|
69
81
|
The plugin hot-loads into a running dsh — adding or removing the bundle reloads the
|
|
70
82
|
profile. If a given build does not pick it up, restart the host once. Start a new
|
|
71
83
|
session to pick up the SessionStart context, and check the bridge's state with
|
|
72
84
|
`/forge-status` (probe result, wired groups, recent verdicts). To uninstall:
|
|
73
|
-
`dsh plugin --profile <
|
|
74
|
-
|
|
85
|
+
`dsh plugin --profile <profile> remove @agent_forge/forge-dsh` — the bundled forge goes
|
|
86
|
+
with it — or keep it installed but inert with `enabled:false`.
|
|
75
87
|
|
|
76
88
|
## Config
|
|
77
89
|
|
|
@@ -84,8 +96,13 @@ under `~/.dsh/profiles/<profile>/`):
|
|
|
84
96
|
- id: forge-quality
|
|
85
97
|
name: "@agent_forge/forge-dsh"
|
|
86
98
|
config:
|
|
87
|
-
forgeBin: forge # binary name/path
|
|
99
|
+
forgeBin: forge # binary name/path. Precedence: this key > FORGE_BIN > bundled forge > PATH `forge`
|
|
88
100
|
timeoutMs: 30000 # per-hook ceiling; timeout kills and FAILS OPEN
|
|
101
|
+
startWaitMs: 1500 # first pre-step waits this long for session-start contexts
|
|
102
|
+
welcome: true # one first-turn line telling the user Forge is active (false = silent)
|
|
103
|
+
checkLatest: true # /forge-status compares the plugin version with the registry's latest (false = never ask)
|
|
104
|
+
locale: auto # language of the plugin's own messages: en | zh | auto (env FORGE_DSH_LOCALE, then system)
|
|
105
|
+
batch: auto # one forge process per matcher group: auto (default, needs a forge of the plugin's own version) | true | false
|
|
89
106
|
enabled: true # false unwires every listener
|
|
90
107
|
debug: false # log fail-open hook errors to console.error
|
|
91
108
|
```
|
|
@@ -101,37 +118,77 @@ design; surface them with `/forge-status` or `debug:true`.
|
|
|
101
118
|
|
|
102
119
|
A missing `forge` binary is the one exception to that silence: each session start
|
|
103
120
|
probes `forge --version` once (until a probe succeeds — the answer is then cached
|
|
104
|
-
for the process lifetime) and, when the probe fails,
|
|
105
|
-
naming the configured `forgeBin`. Without it an uninstalled CLI would look exactly
|
|
121
|
+
for the process lifetime) and, when the probe fails, queues a visible notice
|
|
122
|
+
(delivered with the first pre-step) naming the configured `forgeBin`. Without it an uninstalled CLI would look exactly
|
|
106
123
|
like a healthy plugin gating nothing. The notice is informational, not a switch —
|
|
107
124
|
while the binary is missing, every hook attempt fails open on its own, so gates are
|
|
108
125
|
inactive whether or not the probe ran. Installing forge needs no host restart: the
|
|
109
126
|
next session probes again and gating resumes.
|
|
110
127
|
|
|
111
|
-
Two latency
|
|
112
|
-
group
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
`
|
|
119
|
-
|
|
128
|
+
Two latency points to know. A matched group can run as **one** `forge hook batch` process
|
|
129
|
+
(forge runs the group's hooks in order and stops at the first block) instead of one process
|
|
130
|
+
per hook — roughly 2.4× faster on a measured PreToolUse/Bash group (716ms vs 1720ms on a loaded
|
|
131
|
+
dev machine). With the default `batch: auto` that happens only once the probe shows a forge of the
|
|
132
|
+
plugin's own version: batch asks forge to pick the group by the plugin's matcher string, and a forge
|
|
133
|
+
that disagrees would either print nothing on a block (older than the stdout fix) or silently run an
|
|
134
|
+
empty group, so any other forge stays on the per-hook path, which depends on hook names only. `batch:
|
|
135
|
+
true` forces batch **and gives that protection up** (with a mismatched forge a group can run empty without
|
|
136
|
+
any error); use it only with a forge you know matches. `false` forces per-hook. Whatever the mode, a batch that fails is never taken as an
|
|
137
|
+
allow: that group is re-run per hook for the call (hooks the batch already ran may run again;
|
|
138
|
+
observation-only ones are de-duplicated by forge). Three failures in a row switch batch off; a batch
|
|
139
|
+
that **times out** keeps just that group per-hook for ten minutes, then tries it again, because batch gives
|
|
140
|
+
the whole group one `timeoutMs` and a single hung hook would otherwise starve the gates behind it. The
|
|
141
|
+
three-failures count is per group. `/forge-status` shows
|
|
142
|
+
all of this. And a gate that legitimately exceeds `timeoutMs` (e.g. `auto-compile` cold-building a large
|
|
143
|
+
project past 30s) silently loses that run's feedback to fail-open (visible in `/forge-status`). Raise
|
|
144
|
+
`timeoutMs` on big projects.
|
|
145
|
+
|
|
146
|
+
A session whose gates keep failing open used to look healthy while checking nothing. After
|
|
147
|
+
three fail-open runs in one session the plugin says so **once**, at the next Stop that is not
|
|
148
|
+
already steering for a real block: how many runs lost their feedback, where to look
|
|
149
|
+
(`/forge-status`) and what to raise (`timeoutMs`). It is advisory — nothing is blocked. The batch
|
|
150
|
+
to per-hook downgrade is not counted: that run still executed its gates.
|
|
151
|
+
|
|
152
|
+
The plugin delivers the bridge and (as a dependency) the forge CLI, **not the skill library**
|
|
153
|
+
DSH reads from `~/.agents/skills`. When that directory holds no skill, the first-turn onboarding adds
|
|
154
|
+
one line offering `forge skills install --global --target agents`; the model runs it only after
|
|
155
|
+
the user agrees, since it writes the home directory. The offer is made once per host process, so
|
|
156
|
+
a user who declines is not asked again at every new session. Resume, subagents and
|
|
157
|
+
`welcome: false` sessions get no offer.
|
|
158
|
+
|
|
159
|
+
Whether forge takes over a project is asked once per project — the same on every host. Forge
|
|
160
|
+
does not infer consent from "the plugin is installed" (`init-suggest`'s `takeover` preference is
|
|
161
|
+
the opt-in), so a DSH install behaves like a Claude Code plugin install here; the model relays
|
|
162
|
+
the question from forge's own `init-suggest` advisory, which the receipt filter below never drops.
|
|
163
|
+
|
|
164
|
+
Status-only receipts that forge prints when it has nothing to say (`no weakening detected`,
|
|
165
|
+
`all skills SAFE`, `no .mcp.json` …) are dropped before injection — the host turns every allow
|
|
166
|
+
context into a synthetic user message, and these carry no instruction. Anything else, including
|
|
167
|
+
notes the plugin has never seen, passes through.
|
|
168
|
+
|
|
169
|
+
`/forge-status` opens with `plugin: <installed> latest: <registry>` — an old install keeps working but
|
|
170
|
+
silently misses fixes, and this is the one place a user looks when something seems off. It is a single
|
|
171
|
+
read-only request with a 3-second timeout, made only when the command runs (never at load or session
|
|
172
|
+
start); offline, a private registry or an odd answer shows `unknown`, and `checkLatest: false` never asks.
|
|
173
|
+
It also reports a `dispatch:` line (batch or per-hook) and the probe line after the
|
|
174
|
+
header: `probe: ok (forge version …)`, `probe: FAILED — gates inactive (…)`, or `not run
|
|
175
|
+
yet` before the process's first session start. It also lists wall time per interception
|
|
176
|
+
point (runs, average, max) and each recent run's duration.
|
|
120
177
|
|
|
121
178
|
## Known watch items (dsh preview)
|
|
122
179
|
|
|
123
180
|
- **No stop-hook loop guard** in the preview builds (`0.1.0-rc.7` – `0.2.1-rc.2`) —
|
|
124
181
|
a permanently-failing Stop gate steers at every stop boundary. forge's Stop gates
|
|
125
182
|
are self-limiting once their gate passes, but a stuck gate loops the turn.
|
|
126
|
-
- **SessionStart context
|
|
127
|
-
|
|
128
|
-
|
|
183
|
+
- **SessionStart context rides the first pre-step**: the detached run is awaited for at
|
|
184
|
+
most `startWaitMs`; a session whose first step arrives sooner than the run finishes
|
|
185
|
+
gets the context with its next step instead of the first.
|
|
129
186
|
- DSH is a developer preview; pin this plugin's version alongside your dsh version.
|
|
130
187
|
|
|
131
188
|
## Development
|
|
132
189
|
|
|
133
190
|
```sh
|
|
134
|
-
npm install #
|
|
191
|
+
npm install --no-package-lock # @deepseek-ai/cordis for the wiring tests (+ the bundled forge, unused by them)
|
|
135
192
|
npm test # mapping, runner (incl. cross-OS planSpawn units), decision folding, real-cordis wiring
|
|
136
193
|
```
|
|
137
194
|
|
package/lib/bundled.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — bundled forge binary resolution.
|
|
3
|
+
*
|
|
4
|
+
* The plugin depends on `@agent_forge/forge` (version-locked to the plugin by the
|
|
5
|
+
* release train), so installing the plugin through DSH's pnpm also installs the
|
|
6
|
+
* forge CLI, and uninstalling removes it. That package ships no install script —
|
|
7
|
+
* its binary arrives as a per-platform optionalDependency
|
|
8
|
+
* (`@agent_forge/forge-<os>-<arch>`, os/cpu-filtered by the package manager) — so
|
|
9
|
+
* resolving it here needs no PATH entry and skips the `forge` node shim
|
|
10
|
+
* (~200ms per spawn).
|
|
11
|
+
*
|
|
12
|
+
* 插件依赖 `@agent_forge/forge`(与插件同版本发版),经 DSH 的 pnpm 安装插件时
|
|
13
|
+
* 本体随之安装、卸载插件时随之移除。本体二进制来自平台子包,这里直接解析其
|
|
14
|
+
* 绝对路径:不依赖 PATH,也省掉 node shim 的每次约 200ms。
|
|
15
|
+
*
|
|
16
|
+
* @module bundled
|
|
17
|
+
*/
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
|
|
20
|
+
/** Platforms the forge npm package ships a binary for (npm/run.js SUPPORTED). */
|
|
21
|
+
const SUPPORTED = new Set(["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-x64"]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Platform subpackage name and executable file name for one os/arch, or
|
|
25
|
+
* undefined when forge ships no binary for it.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} [platform] - process.platform override (tests).
|
|
28
|
+
* @param {string} [arch] - process.arch override (tests).
|
|
29
|
+
* @returns {{name: string, exe: string}|undefined}
|
|
30
|
+
*/
|
|
31
|
+
export function platformPackage(platform = process.platform, arch = process.arch) {
|
|
32
|
+
const key = `${platform}-${arch}`;
|
|
33
|
+
if (!SUPPORTED.has(key)) return undefined;
|
|
34
|
+
return { name: `@agent_forge/forge-${key}`, exe: platform === "win32" ? "forge.exe" : "forge" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Absolute path of the bundled forge binary, or undefined when it is not
|
|
39
|
+
* installed (dependency omitted, optional subpackage skipped, unsupported
|
|
40
|
+
* platform). Never throws — an unresolvable bundle simply falls back to the
|
|
41
|
+
* PATH `forge`, and the missing-CLI probe covers the rest.
|
|
42
|
+
*
|
|
43
|
+
* Resolution tries the platform package as seen from `@agent_forge/forge`
|
|
44
|
+
* first (correct under pnpm's isolated linker, where the subpackage is only
|
|
45
|
+
* reachable from the main package), then directly from the plugin (hoisted
|
|
46
|
+
* layout, which is what DSH profiles use).
|
|
47
|
+
*
|
|
48
|
+
* @param {object} [opts]
|
|
49
|
+
* @param {string} [opts.platform]
|
|
50
|
+
* @param {string} [opts.arch]
|
|
51
|
+
* @param {string|URL} [opts.from] - module URL resolution starts from (tests).
|
|
52
|
+
* @returns {string|undefined}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveBundledForge(opts = {}) {
|
|
55
|
+
const pkg = platformPackage(opts.platform, opts.arch);
|
|
56
|
+
if (pkg === undefined) return undefined;
|
|
57
|
+
const target = `${pkg.name}/bin/${pkg.exe}`;
|
|
58
|
+
const pluginRequire = createRequire(opts.from ?? import.meta.url);
|
|
59
|
+
try {
|
|
60
|
+
const mainPackageJson = pluginRequire.resolve("@agent_forge/forge/package.json");
|
|
61
|
+
return createRequire(mainPackageJson).resolve(target);
|
|
62
|
+
} catch {
|
|
63
|
+
// main package absent or its subpackage not reachable from it — try the plugin's own view
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return pluginRequire.resolve(target);
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/lib/decisions.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* @module decisions
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { runForgeHook } from "./runner.js";
|
|
21
|
+
import { runForgeBatch, runForgeHook } from "./runner.js";
|
|
22
22
|
import { pluginMessage } from "./tools.js";
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -26,6 +26,7 @@ import { pluginMessage } from "./tools.js";
|
|
|
26
26
|
* @property {{reason: string, command: string}|undefined} blocked
|
|
27
27
|
* @property {string[]} contexts - allow-path additionalContext, in order.
|
|
28
28
|
* @property {string[]} errors - fail-open infrastructure notes.
|
|
29
|
+
* @property {number} [durationMs] - wall time of the group run (for /forge-status).
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -39,7 +40,8 @@ import { pluginMessage } from "./tools.js";
|
|
|
39
40
|
*/
|
|
40
41
|
export async function runHookGroup(commands, payload, opts = {}) {
|
|
41
42
|
const run = opts.runner ?? runForgeHook; // DI seam for tests
|
|
42
|
-
const
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
const outcome = { blocked: undefined, contexts: [], errors: [], durationMs: 0 };
|
|
43
45
|
for (const command of commands) {
|
|
44
46
|
const verdict = await run(command, payload, opts);
|
|
45
47
|
if (verdict.error !== undefined) outcome.errors.push(`${command}: ${verdict.error}`);
|
|
@@ -49,6 +51,131 @@ export async function runHookGroup(commands, payload, opts = {}) {
|
|
|
49
51
|
}
|
|
50
52
|
if (verdict.context !== undefined) outcome.contexts.push(verdict.context);
|
|
51
53
|
}
|
|
54
|
+
outcome.durationMs = Date.now() - started;
|
|
55
|
+
return outcome;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Status-only receipts forge prints when it has nothing to say ("no weakening
|
|
60
|
+
* detected", "all skills SAFE", "no .mcp.json" …). The host injects every
|
|
61
|
+
* allow-path context into the conversation as a synthetic user message, so each
|
|
62
|
+
* of these costs the model a turn-sized distraction for zero information. Only
|
|
63
|
+
* receipts confirmed to carry no instruction are listed; an allowlist of "what
|
|
64
|
+
* the model must see" would silently drop any new advisory, so unknown notes
|
|
65
|
+
* pass through. A note that tells anyone to DO something carries `Advisory:` or
|
|
66
|
+
* is a directive and never matches here.
|
|
67
|
+
*
|
|
68
|
+
* forge 在「无事可报」时输出的纯状态回执。宿主把 allow 路径的上下文注入成合成 user
|
|
69
|
+
* 消息,每条都白白占用模型一次注意力。只列已确认不含任何指令的回执;未知的一律放行
|
|
70
|
+
* (白名单会静默吞掉新增的 advisory)。要求任何人做事的提示带 `Advisory:` 或是指令,
|
|
71
|
+
* 不会命中这里。
|
|
72
|
+
*/
|
|
73
|
+
const QUIET_NOTES = [
|
|
74
|
+
/^\[assertion-check\] no weakening detected/,
|
|
75
|
+
/^\[auto-compile\] (?:no source touched|research-mode session)/,
|
|
76
|
+
/^\[skill-scan\] (?:all skills SAFE|no ~\/\.claude\/skills)/,
|
|
77
|
+
/^\[mcp-scan\] (?:no |\.mcp\.json unreadable|项目级 \.mcp\.json 无 config 层风险信号)/,
|
|
78
|
+
/^\[workflow-test-guard\] workflow 配置变更后 internal\/ci 守护测试全绿/,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove the quiet receipts from one context string. Judged per paragraph (a
|
|
83
|
+
* batch merges the group's contexts with a blank line between them); a paragraph
|
|
84
|
+
* is quiet when its first line matches. Returns "" when nothing is left.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} text
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function dropQuietNotes(text) {
|
|
90
|
+
return text
|
|
91
|
+
.split("\n\n")
|
|
92
|
+
.filter((paragraph) => !QUIET_NOTES.some((re) => re.test(paragraph)))
|
|
93
|
+
.join("\n\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Marks an outcome error as "this group ran per hook instead of batched" — the
|
|
98
|
+
* run still executed its gates, so it is a performance note, not a lost run, and
|
|
99
|
+
* the fail-open counter must not count it (index.js reads this prefix).
|
|
100
|
+
*/
|
|
101
|
+
export const BATCH_DOWNGRADE_MARK = "[batch→per-hook]";
|
|
102
|
+
|
|
103
|
+
/** Consecutive unexplained batch failures of one group before batch is switched off for the process. */
|
|
104
|
+
const BATCH_FAIL_LIMIT = 3;
|
|
105
|
+
|
|
106
|
+
/** How long a group that timed out as a batch stays per-hook before batch is tried on it again. */
|
|
107
|
+
const SLOW_RETRY_MS = 10 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run the matched spec groups of one event — one `forge hook batch` process per
|
|
111
|
+
* group instead of one process per hook (the spec's matcher only SELECTS the
|
|
112
|
+
* group; forge owns the roster).
|
|
113
|
+
*
|
|
114
|
+
* Fail-open stays the invariant, with one guard: a batch that cannot answer is
|
|
115
|
+
* never taken as an allow. An error verdict ("exited with code 2 and no output"
|
|
116
|
+
* is what an older forge prints on a block) re-runs THAT group per hook — the
|
|
117
|
+
* legacy path whose contract every forge version honours. Two kinds of failure:
|
|
118
|
+
* - a timeout: batch gives the whole group ONE ceiling, so a single hung hook
|
|
119
|
+
* would starve every gate behind it. The group is re-run per hook (each hook
|
|
120
|
+
* gets its own ceiling again) and stays per-hook for SLOW_RETRY_MS, so a
|
|
121
|
+
* persistently slow group costs one stall, not one per tool call, while a
|
|
122
|
+
* one-off hiccup (cold start, a virus scan) is not held against it forever;
|
|
123
|
+
* - anything else: re-run per hook this once. Only BATCH_FAIL_LIMIT failures of
|
|
124
|
+
* the SAME group in a row switch batch off for the process (a healthy group
|
|
125
|
+
* must not keep resetting another group's streak); one success of that group
|
|
126
|
+
* resets it, so a transient spawn error does not slow every later call.
|
|
127
|
+
* The re-run can execute hooks the batch already ran; observation-only hooks are
|
|
128
|
+
* de-duplicated by forge's idempotency window, blocking hooks simply run again.
|
|
129
|
+
*
|
|
130
|
+
* `opts.batch === false` forces per-hook from the start.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} event - forge event name.
|
|
133
|
+
* @param {Array<{matcher: string, commands: string[]}>} groups - matched spec groups, spec order.
|
|
134
|
+
* @param {object} payload - Claude-Code-shape stdin payload.
|
|
135
|
+
* @param {object} [opts] - forgeBin/timeoutMs plus test seams `batchRunner`, `runner`, `batchState`.
|
|
136
|
+
* @returns {Promise<GroupOutcome>}
|
|
137
|
+
*/
|
|
138
|
+
export async function runMatchedGroups(event, groups, payload, opts = {}) {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
const batchRun = opts.batchRunner ?? runForgeBatch; // DI seam for tests
|
|
141
|
+
const state = opts.batchState ?? { broken: false };
|
|
142
|
+
state.slow ??= new Map(); // label -> when it timed out
|
|
143
|
+
state.failStreaks ??= new Map(); // label -> consecutive failures
|
|
144
|
+
const outcome = { blocked: undefined, contexts: [], errors: [], durationMs: 0 };
|
|
145
|
+
for (const group of groups) {
|
|
146
|
+
const label = `batch ${event}${group.matcher === "" ? "" : `/${group.matcher}`}`;
|
|
147
|
+
let part;
|
|
148
|
+
const slowSince = state.slow.get(label);
|
|
149
|
+
const isSlow = slowSince !== undefined && Date.now() - slowSince < SLOW_RETRY_MS;
|
|
150
|
+
if (opts.batch !== false && !state.broken && !isSlow) {
|
|
151
|
+
const verdict = await batchRun(event, group.matcher, payload, opts);
|
|
152
|
+
if (verdict.error === undefined) {
|
|
153
|
+
state.failStreaks.delete(label);
|
|
154
|
+
state.slow.delete(label);
|
|
155
|
+
part = {
|
|
156
|
+
blocked: verdict.block ? { reason: verdict.reason ?? "denied", command: label } : undefined,
|
|
157
|
+
contexts: verdict.context === undefined ? [] : [verdict.context],
|
|
158
|
+
errors: [],
|
|
159
|
+
};
|
|
160
|
+
} else if (verdict.timedOut === true) {
|
|
161
|
+
state.slow.set(label, Date.now());
|
|
162
|
+
outcome.errors.push(`${BATCH_DOWNGRADE_MARK} ${label}: ${verdict.error} — running this group's hooks one by one so a hung hook cannot starve the gates behind it`);
|
|
163
|
+
} else {
|
|
164
|
+
const streak = (state.failStreaks.get(label) ?? 0) + 1;
|
|
165
|
+
state.failStreaks.set(label, streak);
|
|
166
|
+
if (streak >= BATCH_FAIL_LIMIT) state.broken = true;
|
|
167
|
+
outcome.errors.push(`${BATCH_DOWNGRADE_MARK} ${label}: ${verdict.error} — running this group's hooks one by one`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (part === undefined) part = await runHookGroup(group.commands, payload, opts);
|
|
171
|
+
outcome.errors.push(...part.errors);
|
|
172
|
+
outcome.contexts.push(...part.contexts.map(dropQuietNotes).filter((text) => text !== ""));
|
|
173
|
+
if (part.blocked !== undefined) {
|
|
174
|
+
outcome.blocked = part.blocked;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
outcome.durationMs = Date.now() - started;
|
|
52
179
|
return outcome;
|
|
53
180
|
}
|
|
54
181
|
|
|
@@ -158,16 +285,30 @@ export async function turnStoppingOutcome(outcome, agent) {
|
|
|
158
285
|
}
|
|
159
286
|
|
|
160
287
|
/**
|
|
161
|
-
* agent/session-start (emit)
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* the same
|
|
288
|
+
* agent/session-start (emit) contexts ride the NEXT pre-step instead of
|
|
289
|
+
* agent.inject. Emit listeners are detached and inject at session start was
|
|
290
|
+
* observed to drop the contexts silently (audit 2026-10-20: all four lost,
|
|
291
|
+
* not delayed, while pre-step messages in the same session were delivered), so
|
|
292
|
+
* the queued strings are prepended into the first `enter` decision. A reject
|
|
293
|
+
* (or any non-enter decision) leaves the queue to the caller — it must retain
|
|
294
|
+
* the strings for the next pre-step.
|
|
165
295
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
296
|
+
* 会话启动的上下文改走下一次 pre-step 的 enter 决策(而非 agent.inject):
|
|
297
|
+
* 实测 inject 在会话启动时静默丢弃,pre-step 消息则稳定送达。
|
|
298
|
+
*
|
|
299
|
+
* @param {object} decision - PreStepDecision (after preStepDecision folding).
|
|
300
|
+
* @param {string[]} pending - queued session-start contexts, delivery order.
|
|
301
|
+
* @returns {{decision: object, delivered: boolean}} delivered=true means the
|
|
302
|
+
* caller may clear the queue.
|
|
168
303
|
*/
|
|
169
|
-
export function
|
|
170
|
-
|
|
304
|
+
export function prependStartContexts(decision, pending) {
|
|
305
|
+
if (pending.length === 0 || decision?.kind !== "enter") {
|
|
306
|
+
return { decision, delivered: false };
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
decision: { ...decision, messages: [...pending.map(pluginMessage), ...(decision.messages ?? [])] },
|
|
310
|
+
delivered: true,
|
|
311
|
+
};
|
|
171
312
|
}
|
|
172
313
|
|
|
173
314
|
/** Queue each context string as one producer-attributed user message. */
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* agent/pre-step → UserPromptSubmit (block → {kind:'reject'})
|
|
11
11
|
* agent/session-start → SessionStart (emit; source 'compact' also fires
|
|
12
12
|
* the PostCompact group — DSH rc.7 exposes no
|
|
13
|
-
* separate compaction point)
|
|
13
|
+
* separate compaction point). Its contexts are queued
|
|
14
|
+
* per agent and delivered with the next pre-step.
|
|
14
15
|
* agent/turn-stopping → Stop (block → agent.steer(reason))
|
|
15
16
|
*
|
|
16
17
|
* Each hook runs as `forge hook <name>` with a Claude-Code-shape stdin
|
|
@@ -20,19 +21,39 @@
|
|
|
20
21
|
* @deepseek-ai/dsh 0.1.0-rc.7 type surface.
|
|
21
22
|
*
|
|
22
23
|
* Config (profile patch):
|
|
23
|
-
* forgeBin - forge binary name/path
|
|
24
|
+
* forgeBin - forge binary name/path. Precedence: config > env FORGE_BIN >
|
|
25
|
+
* the forge bundled with this plugin (dependency, version-locked) >
|
|
26
|
+
* "forge" on PATH
|
|
24
27
|
* timeoutMs - per-hook ceiling, kill + fail open (default 30000)
|
|
28
|
+
* locale - language of the plugin's own messages: "en" | "zh" | "auto"
|
|
29
|
+
* (default auto: env FORGE_DSH_LOCALE, then the system locale)
|
|
30
|
+
* checkLatest - false stops /forge-status from asking the registry for the latest plugin
|
|
31
|
+
* version (default true; one read-only GET, only when the command runs)
|
|
32
|
+
* registry - registry /forge-status asks (default https://registry.npmjs.org)
|
|
33
|
+
* skillsHome - home directory whose .agents/skills is checked for the skill library
|
|
34
|
+
* (default: the user's home; a seam for tests)
|
|
35
|
+
* welcome - false drops the one-line first-turn onboarding (default true)
|
|
36
|
+
* startWaitMs - how long the first pre-step waits for the session-start
|
|
37
|
+
* contexts before moving on without them (default 1500)
|
|
25
38
|
* enabled - false disables every listener (default true)
|
|
26
39
|
* debug - log hook failures to console.error (default false)
|
|
27
40
|
*
|
|
28
41
|
* @module @agent_forge/forge-dsh
|
|
29
42
|
*/
|
|
30
43
|
import { readFileSync } from "node:fs";
|
|
44
|
+
import { resolveBundledForge } from "./bundled.js";
|
|
45
|
+
import { compareVersions, fetchLatestVersion, isPlainVersion } from "./latest.js";
|
|
46
|
+
import { messagesFor, resolveLocale } from "./messages.js";
|
|
47
|
+
import { agentsSkillsInstalled } from "./skills.js";
|
|
31
48
|
import { probeForge } from "./runner.js";
|
|
32
|
-
import {
|
|
33
|
-
import { buildEventPayload, buildToolPayload,
|
|
49
|
+
import { BATCH_DOWNGRADE_MARK, runMatchedGroups, preExecuteDecision, postExecuteDecision, preStepDecision, prependStartContexts, turnStoppingOutcome } from "./decisions.js";
|
|
50
|
+
import { buildEventPayload, buildToolPayload, matchedGroups, pluginMessage, promptText, toCCToolName, isSubagent, MANAGED_BANNER } from "./tools.js";
|
|
34
51
|
|
|
35
52
|
const spec = JSON.parse(readFileSync(new URL("./spec.json", import.meta.url), "utf8"));
|
|
53
|
+
const pluginVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
54
|
+
|
|
55
|
+
/** Fail-open runs in one session before the degraded advisory fires. */
|
|
56
|
+
const DEGRADED_AFTER = 3;
|
|
36
57
|
|
|
37
58
|
/** Stable Cordis plugin name. */
|
|
38
59
|
const name = "forge-quality";
|
|
@@ -45,20 +66,53 @@ const inject = ["tools"];
|
|
|
45
66
|
*/
|
|
46
67
|
function apply(ctx, config = {}) {
|
|
47
68
|
if (config.enabled === false) return;
|
|
69
|
+
const explicitBin = config.forgeBin ?? process.env.FORGE_BIN;
|
|
70
|
+
let bundledBin;
|
|
71
|
+
if (explicitBin === undefined) {
|
|
72
|
+
try {
|
|
73
|
+
bundledBin = (config.resolveBundledForge ?? resolveBundledForge)(); // config seam: tests
|
|
74
|
+
} catch {
|
|
75
|
+
bundledBin = undefined; // an unresolvable bundle must never break plugin load
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let binSource = explicitBin !== undefined ? "configured" : bundledBin !== undefined ? "bundled" : "PATH";
|
|
48
79
|
const opts = {
|
|
49
|
-
forgeBin:
|
|
80
|
+
forgeBin: explicitBin ?? bundledBin ?? "forge",
|
|
50
81
|
timeoutMs: config.timeoutMs ?? 30000,
|
|
82
|
+
// One `forge hook batch` process per matcher group instead of one per hook (~half the
|
|
83
|
+
// per-tool-call latency). Config `batch`: true forces it, false forces the per-hook path,
|
|
84
|
+
// unset = auto — on only once the probe shows a forge of the plugin's own version. Batch asks
|
|
85
|
+
// forge to select the group by the plugin's matcher string, and an older or newer forge may
|
|
86
|
+
// not agree (an unmatched matcher is a silent empty allow, and forges before the stdout fix
|
|
87
|
+
// print nothing on a block), so a mismatched forge stays on the per-hook path, which only
|
|
88
|
+
// depends on hook names. Runtime failures degrade per call (see runMatchedGroups).
|
|
89
|
+
batch: config.batch === true,
|
|
90
|
+
batchState: { broken: false },
|
|
51
91
|
};
|
|
92
|
+
const batchMode = config.batch === true ? "forced" : config.batch === false ? "off" : "auto";
|
|
52
93
|
const debug = config.debug === true;
|
|
94
|
+
const welcome = config.welcome !== false;
|
|
95
|
+
let skillsOffered = false;
|
|
96
|
+
const msg = messagesFor(resolveLocale(config.locale));
|
|
97
|
+
const startWaitMs = Number.isFinite(config.startWaitMs) && config.startWaitMs >= 0 ? config.startWaitMs : 1500;
|
|
53
98
|
// Recent-run ring buffer for /forge-status — fail-open verdicts are silent
|
|
54
99
|
// by design, so this (plus debug logging) is the only way to see them.
|
|
55
100
|
const recentRuns = [];
|
|
56
|
-
const noteErrors = (label, outcome) => {
|
|
101
|
+
const noteErrors = (label, outcome, agent) => {
|
|
102
|
+
// Per-session count of runs that lost their feedback to fail-open — the signal behind the
|
|
103
|
+
// one-time degraded advisory at Stop. The batch->per-hook downgrade note is not a lost run
|
|
104
|
+
// (the serial retry ran the gate), so it does not count.
|
|
105
|
+
// And nothing is counted while forge itself is missing: that is the missing-CLI notice's job,
|
|
106
|
+
// and "raise timeoutMs" would be the wrong advice.
|
|
107
|
+
const lost = lastProbe?.ok !== false && outcome.errors.some((e) => !e.startsWith(BATCH_DOWNGRADE_MARK));
|
|
108
|
+
const state = lost ? startStateOf(agent) : undefined;
|
|
109
|
+
if (state !== undefined) state.failOpen = (state.failOpen ?? 0) + 1;
|
|
57
110
|
recentRuns.push({
|
|
58
111
|
label,
|
|
59
112
|
blocked: outcome.blocked?.command ?? null,
|
|
60
113
|
contexts: outcome.contexts.length,
|
|
61
114
|
errors: outcome.errors,
|
|
115
|
+
ms: outcome.durationMs ?? 0,
|
|
62
116
|
at: Date.now(),
|
|
63
117
|
});
|
|
64
118
|
if (recentRuns.length > 50) recentRuns.shift();
|
|
@@ -67,23 +121,56 @@ function apply(ctx, config = {}) {
|
|
|
67
121
|
}
|
|
68
122
|
};
|
|
69
123
|
|
|
124
|
+
// Session-start contexts (forge's own onboarding + the missing-forge notice)
|
|
125
|
+
// are queued per agent and prepended into that agent's next pre-step.
|
|
126
|
+
// agent.inject at session start was observed to drop them silently, while
|
|
127
|
+
// pre-step messages in the same session were delivered. The session-start
|
|
128
|
+
// run is detached and takes a few hundred ms, so the first pre-step may
|
|
129
|
+
// arrive before it finishes: `ready` lets that pre-step wait a bounded time
|
|
130
|
+
// (startWaitMs) instead of pushing the onboarding to turn 2.
|
|
131
|
+
// Keyed by session id when the handle has one (the host may hand each event a
|
|
132
|
+
// fresh wrapper object for the same session), by object identity otherwise.
|
|
133
|
+
const startStates = new WeakMap();
|
|
134
|
+
const startStatesById = new Map();
|
|
135
|
+
const startStateOf = (agent) => {
|
|
136
|
+
if (agent === null || typeof agent !== "object") return undefined;
|
|
137
|
+
const id = agent.session?.id;
|
|
138
|
+
const byId = typeof id === "string" && id !== "";
|
|
139
|
+
let state = byId ? startStatesById.get(id) : startStates.get(agent);
|
|
140
|
+
if (state === undefined) {
|
|
141
|
+
state = { pending: [], ready: Promise.resolve(), waited: true };
|
|
142
|
+
if (byId) startStatesById.set(id, state);
|
|
143
|
+
else startStates.set(agent, state);
|
|
144
|
+
}
|
|
145
|
+
return state;
|
|
146
|
+
};
|
|
147
|
+
// Only the first pre-step after a session-start waits: a slow start run must
|
|
148
|
+
// not tax every later step by startWaitMs; its contexts simply land on the
|
|
149
|
+
// first enter after it finishes.
|
|
150
|
+
const waitStart = async (state) => {
|
|
151
|
+
if (state === undefined || state.waited) return;
|
|
152
|
+
state.waited = true;
|
|
153
|
+
let timer;
|
|
154
|
+
const timeout = new Promise((resolve) => {
|
|
155
|
+
timer = setTimeout(resolve, startWaitMs);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
await Promise.race([state.ready, timeout]);
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
70
165
|
// A missing forge binary used to be completely silent: every hook failed
|
|
71
166
|
// open, so the plugin looked installed but gated nothing. Probe once per
|
|
72
167
|
// session start (retrying on later sessions, since installing forge needs no
|
|
73
|
-
// host restart) and surface the failure through
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// instance, so it cannot go stale.
|
|
168
|
+
// host restart) and surface the failure through the same start queue. Once a
|
|
169
|
+
// probe succeeds the answer is cached for the process lifetime; forgeBin is
|
|
170
|
+
// static config per plugin instance, so it cannot go stale.
|
|
77
171
|
let forgeReady = false;
|
|
78
172
|
let lastProbe;
|
|
79
173
|
let probeInFlight;
|
|
80
|
-
const noticedAgents = new WeakSet();
|
|
81
|
-
const missingForgeNotice = (error) =>
|
|
82
|
-
`[forge-dsh] forge CLI not found (forgeBin: ${opts.forgeBin}). ` +
|
|
83
|
-
"Forge gates are OFF for this session — every action runs unguarded (fail-open). " +
|
|
84
|
-
"Install it with `npm install -g @agent_forge/forge`, then start a new session; " +
|
|
85
|
-
"if it lives elsewhere, set forgeBin in the profile patch." +
|
|
86
|
-
(error === undefined ? "" : `\nProbe error: ${error}`);
|
|
87
174
|
const ensureForge = async (agent) => {
|
|
88
175
|
if (forgeReady) return true;
|
|
89
176
|
if (probeInFlight === undefined) {
|
|
@@ -92,92 +179,169 @@ function apply(ctx, config = {}) {
|
|
|
92
179
|
probeInFlight = undefined;
|
|
93
180
|
});
|
|
94
181
|
}
|
|
95
|
-
|
|
182
|
+
let probe = await probeInFlight;
|
|
183
|
+
// A bundled binary that resolved but will not run (wrong arch, lost exec bit,
|
|
184
|
+
// truncated download) must not void the gates when a working forge is on PATH.
|
|
185
|
+
if (!probe.ok && binSource === "bundled") {
|
|
186
|
+
const onPath = await probeForge({ forgeBin: "forge", timeoutMs: Math.min(opts.timeoutMs, 5000) });
|
|
187
|
+
if (onPath.ok) {
|
|
188
|
+
if (debug) console.error(`[forge-dsh] bundled forge unusable (${probe.error}); using PATH forge`);
|
|
189
|
+
opts.forgeBin = "forge";
|
|
190
|
+
binSource = "PATH";
|
|
191
|
+
probe = onPath;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
96
194
|
lastProbe = probe;
|
|
97
195
|
if (probe.ok) {
|
|
98
196
|
forgeReady = true;
|
|
197
|
+
if (batchMode === "auto") opts.batch = (probe.version?.match(/\d+\.\d+\.\d+/) ?? [])[0] === pluginVersion;
|
|
99
198
|
return true;
|
|
100
199
|
}
|
|
101
200
|
if (debug) console.error(`[forge-dsh] forge probe failed: ${probe.error}`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
} catch {
|
|
107
|
-
// a throwing (or rejecting) inject must never interrupt session start
|
|
108
|
-
}
|
|
201
|
+
const state = startStateOf(agent);
|
|
202
|
+
if (state !== undefined && !state.noticed) {
|
|
203
|
+
state.noticed = true;
|
|
204
|
+
state.pending.push(msg.missingForge(opts.forgeBin, probe.error));
|
|
109
205
|
}
|
|
110
206
|
return false;
|
|
111
207
|
};
|
|
112
208
|
|
|
113
209
|
// PreToolUse — deny short-circuits the tool call.
|
|
114
210
|
ctx.effect(() => ctx.on("tools/pre-execute", async (exec, next) => {
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
const outcome = await
|
|
118
|
-
noteErrors("pre-execute", outcome);
|
|
211
|
+
const groups = matchedGroups(spec.PreToolUse, toCCToolName(exec?.name ?? ""));
|
|
212
|
+
if (groups.length === 0) return next();
|
|
213
|
+
const outcome = await runMatchedGroups("PreToolUse", groups, buildToolPayload(exec, "PreToolUse"), opts);
|
|
214
|
+
noteErrors("pre-execute", outcome, exec?.agent);
|
|
119
215
|
return preExecuteDecision(outcome, exec?.agent, next);
|
|
120
216
|
}), "forge: pre-execute");
|
|
121
217
|
|
|
122
218
|
// PostToolUse — block turns the gate's reason into an error result.
|
|
123
219
|
ctx.effect(() => ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
const outcome = await
|
|
127
|
-
noteErrors("post-execute", outcome);
|
|
220
|
+
const groups = matchedGroups(spec.PostToolUse, toCCToolName(exec?.name ?? ""));
|
|
221
|
+
if (groups.length === 0) return next();
|
|
222
|
+
const outcome = await runMatchedGroups("PostToolUse", groups, buildToolPayload(exec, "PostToolUse"), opts);
|
|
223
|
+
noteErrors("post-execute", outcome, exec?.agent);
|
|
128
224
|
return postExecuteDecision(outcome, next, exec?.agent);
|
|
129
225
|
}), "forge: post-execute");
|
|
130
226
|
|
|
131
227
|
// UserPromptSubmit — the prompt rides the step's claimed messages.
|
|
132
228
|
ctx.effect(() => ctx.on("agent/pre-step", async (payload, next) => {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
229
|
+
const state = startStateOf(payload?.agent);
|
|
230
|
+
const startWait = waitStart(state);
|
|
231
|
+
const groups = matchedGroups(spec.UserPromptSubmit, "");
|
|
232
|
+
let decision;
|
|
233
|
+
if (groups.length === 0) {
|
|
234
|
+
decision = await next();
|
|
235
|
+
} else {
|
|
236
|
+
const eventPayload = buildEventPayload("UserPromptSubmit", payload?.agent, {
|
|
237
|
+
prompt: promptText(payload?.messages),
|
|
238
|
+
});
|
|
239
|
+
const outcome = await runMatchedGroups("UserPromptSubmit", groups, eventPayload, opts);
|
|
240
|
+
noteErrors("pre-step", outcome, payload?.agent);
|
|
241
|
+
decision = await preStepDecision(outcome, next, payload?.agent);
|
|
242
|
+
}
|
|
243
|
+
await startWait;
|
|
244
|
+
if (state === undefined) return decision;
|
|
245
|
+
// a reject keeps the queue for the next pre-step; only an enter clears it
|
|
246
|
+
const delivery = prependStartContexts(decision, state.pending);
|
|
247
|
+
if (delivery.delivered) state.pending.length = 0;
|
|
248
|
+
return delivery.decision;
|
|
141
249
|
}), "forge: pre-step");
|
|
142
250
|
|
|
143
|
-
// SessionStart — emit mode: detached, contexts queued
|
|
251
|
+
// SessionStart — emit mode: detached, contexts queued per agent and
|
|
252
|
+
// delivered by the next pre-step (see startStates above).
|
|
144
253
|
// source 'compact' doubles as PostCompact (DSH exposes no dedicated point).
|
|
145
254
|
ctx.effect(() => ctx.on("agent/session-start", (payload) => {
|
|
146
255
|
const agent = payload?.agent;
|
|
147
256
|
const source = payload?.source;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
257
|
+
const state = startStateOf(agent);
|
|
258
|
+
const queue = (outcome) => {
|
|
259
|
+
if (state !== undefined) {
|
|
260
|
+
// overlapping starts must not stack the same onboarding twice
|
|
261
|
+
for (const text of outcome.contexts) if (!state.pending.includes(text)) state.pending.push(text);
|
|
262
|
+
} else if (debug && outcome.contexts.length > 0) {
|
|
263
|
+
console.error("[forge-dsh] session-start contexts dropped: no agent handle to queue them on");
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const run = (async () => {
|
|
267
|
+
const forgeOk = await ensureForge(agent);
|
|
268
|
+
// The bundled binary is not on PATH while forge's gate messages say
|
|
269
|
+
// `forge task …`, so the agent is told where it is. After a compaction the
|
|
270
|
+
// earlier hint is summarised away, so it is re-sent.
|
|
271
|
+
if (source === "compact" && state !== undefined) state.hinted = false;
|
|
272
|
+
if (forgeOk && binSource === "bundled" && state !== undefined && !state.hinted) {
|
|
273
|
+
state.hinted = true;
|
|
274
|
+
state.pending.push(msg.bundledHint(opts.forgeBin));
|
|
275
|
+
}
|
|
276
|
+
const start = await runMatchedGroups(
|
|
277
|
+
"SessionStart",
|
|
278
|
+
matchedGroups(spec.SessionStart, ""),
|
|
152
279
|
buildEventPayload("SessionStart", agent, { source }),
|
|
153
280
|
opts,
|
|
154
281
|
);
|
|
155
|
-
noteErrors("session-start", start);
|
|
156
|
-
|
|
282
|
+
noteErrors("session-start", start, agent);
|
|
283
|
+
queue(start);
|
|
284
|
+
// Welcome — queue order: welcome, path hint, forge's own contexts. One
|
|
285
|
+
// human-facing line per session: startup only (resume/compact are not a
|
|
286
|
+
// first meeting), never for delegated children (they report to their
|
|
287
|
+
// parent), and only when forge itself announced the project as managed —
|
|
288
|
+
// a working binary alone does not mean the gates apply here (no `forge
|
|
289
|
+
// init`, or `forge off`).
|
|
290
|
+
const isStartup = source === undefined || source === "startup";
|
|
291
|
+
const managed = start.contexts.some((text) => text.includes(MANAGED_BANNER));
|
|
292
|
+
if (forgeOk && welcome && isStartup && managed && !isSubagent(agent) && state !== undefined && !state.welcomed) {
|
|
293
|
+
state.welcomed = true;
|
|
294
|
+
const forgeVersion = (lastProbe?.version?.match(/\d+\.\d+\.\d+/) ?? [])[0];
|
|
295
|
+
state.pending.unshift(msg.welcome(pluginVersion, forgeVersion));
|
|
296
|
+
// The plugin brings the bridge and the forge CLI, not the skill library DSH reads from
|
|
297
|
+
// ~/.agents/skills; offer it (right behind the welcome) when that directory is empty.
|
|
298
|
+
// Once per plugin instance: there is no persisted "declined", so asking again at every
|
|
299
|
+
// new session would nag a user who already said no.
|
|
300
|
+
if (!skillsOffered && !agentsSkillsInstalled(config.skillsHome)) {
|
|
301
|
+
skillsOffered = true;
|
|
302
|
+
state.pending.splice(1, 0, msg.skillsHint(opts.forgeBin));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
157
305
|
if (source === "compact") {
|
|
158
|
-
const compact = await
|
|
159
|
-
|
|
306
|
+
const compact = await runMatchedGroups(
|
|
307
|
+
"PostCompact",
|
|
308
|
+
matchedGroups(spec.PostCompact, ""),
|
|
160
309
|
buildEventPayload("PostCompact", agent, {}),
|
|
161
310
|
opts,
|
|
162
311
|
);
|
|
163
|
-
noteErrors("post-compact", compact);
|
|
164
|
-
|
|
312
|
+
noteErrors("post-compact", compact, agent);
|
|
313
|
+
queue(compact);
|
|
165
314
|
}
|
|
166
315
|
})().catch((error) => {
|
|
167
316
|
if (debug) console.error(`[forge-dsh] session-start failed open: ${error}`);
|
|
168
317
|
});
|
|
318
|
+
// overlapping session-starts (startup then compact) both gate the pre-step
|
|
319
|
+
if (state !== undefined) {
|
|
320
|
+
state.waited = false;
|
|
321
|
+
state.ready = Promise.all([state.ready, run]);
|
|
322
|
+
}
|
|
169
323
|
}), "forge: session-start");
|
|
170
324
|
|
|
171
325
|
// Stop — a blocking gate steers the agent into another step.
|
|
172
326
|
ctx.effect(() => ctx.on("agent/turn-stopping", async (payload) => {
|
|
173
327
|
const agent = payload?.agent;
|
|
174
|
-
const outcome = await
|
|
175
|
-
|
|
328
|
+
const outcome = await runMatchedGroups(
|
|
329
|
+
"Stop",
|
|
330
|
+
matchedGroups(spec.Stop, ""),
|
|
176
331
|
buildEventPayload("Stop", agent, { stop_hook_active: false }),
|
|
177
332
|
opts,
|
|
178
333
|
);
|
|
179
|
-
noteErrors("turn-stopping", outcome);
|
|
180
|
-
|
|
334
|
+
noteErrors("turn-stopping", outcome, agent);
|
|
335
|
+
const steering = await turnStoppingOutcome(outcome, agent);
|
|
336
|
+
// Fail-open is silent by design, so a session where gates keep timing out looks healthy while
|
|
337
|
+
// checking nothing. Once per session, at the first Stop that is not already steering for a
|
|
338
|
+
// real block, say so (advisory — it blocks nothing). The count is per session, not global.
|
|
339
|
+
const state = startStateOf(agent);
|
|
340
|
+
if (outcome.blocked === undefined && state !== undefined && (state.failOpen ?? 0) >= DEGRADED_AFTER && !state.degradedNoted) {
|
|
341
|
+
state.degradedNoted = true;
|
|
342
|
+
agent?.steer?.(pluginMessage(msg.degraded(state.failOpen)));
|
|
343
|
+
}
|
|
344
|
+
return steering;
|
|
181
345
|
}), "forge: turn-stopping");
|
|
182
346
|
|
|
183
347
|
// /forge-status — wired groups + verdict summary + recent runs (the only
|
|
@@ -212,9 +376,45 @@ function apply(ctx, config = {}) {
|
|
|
212
376
|
const parts = [` ${new Date(r.at).toISOString()} ${r.label}`];
|
|
213
377
|
if (r.blocked) parts.push(`blocked-by=${r.blocked}`);
|
|
214
378
|
if (r.contexts > 0) parts.push(`contexts=${r.contexts}`);
|
|
379
|
+
parts.push(`${r.ms}ms`);
|
|
215
380
|
if (r.errors.length > 0) parts.push(`FAIL-OPEN: ${r.errors.join("; ")}`);
|
|
216
381
|
return parts.join(" ");
|
|
217
382
|
}).join("\n");
|
|
383
|
+
// Per-interception-point wall time over the ring buffer: the number behind "why is every
|
|
384
|
+
// tool call slow" (one batch spawn per group should sit well under the per-hook sum).
|
|
385
|
+
const byLabel = new Map();
|
|
386
|
+
for (const r of recentRuns) {
|
|
387
|
+
const t = byLabel.get(r.label) ?? { runs: 0, total: 0, max: 0 };
|
|
388
|
+
t.runs += 1;
|
|
389
|
+
t.total += r.ms;
|
|
390
|
+
t.max = Math.max(t.max, r.ms);
|
|
391
|
+
byLabel.set(r.label, t);
|
|
392
|
+
}
|
|
393
|
+
const timing = byLabel.size === 0
|
|
394
|
+
? " (no hook runs yet)"
|
|
395
|
+
: [...byLabel].map(([label, t]) => ` ${label} runs=${t.runs} avg=${Math.round(t.total / t.runs)}ms max=${t.max}ms`).join("\n");
|
|
396
|
+
const forgeVersion = (lastProbe?.version?.match(/\d+\.\d+\.\d+/) ?? [])[0];
|
|
397
|
+
const slowGroups = opts.batchState.slow === undefined ? [] : [...opts.batchState.slow.keys()];
|
|
398
|
+
let dispatchLine;
|
|
399
|
+
if (batchMode === "off") dispatchLine = "per-hook (batch:false)";
|
|
400
|
+
else if (opts.batchState.broken) dispatchLine = "per-hook (batch failed three times in a row; restart the host to retry)";
|
|
401
|
+
else if (!opts.batch) {
|
|
402
|
+
dispatchLine = lastProbe === undefined
|
|
403
|
+
? "per-hook (waiting for the first session start to check the forge version)"
|
|
404
|
+
: `per-hook (forge ${forgeVersion ?? "of unknown version"} != plugin ${pluginVersion}: batch needs a forge of the plugin's own version)`;
|
|
405
|
+
} else dispatchLine = "batch (one forge process per matcher group)";
|
|
406
|
+
if (slowGroups.length > 0) dispatchLine += `; per-hook for slow group${slowGroups.length > 1 ? "s" : ""}: ${slowGroups.join(", ")}`;
|
|
407
|
+
let latestLine;
|
|
408
|
+
if (config.checkLatest === false) {
|
|
409
|
+
latestLine = "not checked (checkLatest:false)";
|
|
410
|
+
} else {
|
|
411
|
+
const latest = await (config.fetchLatest ?? (() => fetchLatestVersion({ registry: config.registry })))();
|
|
412
|
+
// an injected or misbehaving source may hand back anything: only plain semver is shown
|
|
413
|
+
if (latest === undefined || !isPlainVersion(latest)) latestLine = "unknown (offline, or the registry did not answer)";
|
|
414
|
+
else if (compareVersions(pluginVersion, latest) === -1) {
|
|
415
|
+
latestLine = `${latest} — a newer plugin is published; an old install keeps working but misses fixes, so update it`;
|
|
416
|
+
} else latestLine = `${latest} (up to date)`;
|
|
417
|
+
}
|
|
218
418
|
const probeLine = lastProbe === undefined
|
|
219
419
|
? "not run yet (no session start in this process)"
|
|
220
420
|
: lastProbe.ok
|
|
@@ -222,7 +422,7 @@ function apply(ctx, config = {}) {
|
|
|
222
422
|
: `FAILED — gates inactive (${lastProbe.error})`;
|
|
223
423
|
return {
|
|
224
424
|
kind: "success",
|
|
225
|
-
text: `# Forge quality gates\n\nforgeBin: ${opts.forgeBin} timeoutMs: ${opts.timeoutMs}\nprobe: ${probeLine}\n\n## Wired groups\n${groups}\n\n## Verdict summary (last ${recentRuns.length} runs)\n runs=${summary.runs} blocked=${summary.blocked} fail-open=${summary.failOpen} contexts=${summary.contexts}\n\n## Recent runs (last 10)\n${runs}`,
|
|
425
|
+
text: `# Forge quality gates\n\nplugin: ${pluginVersion} latest: ${latestLine}\nforgeBin: ${opts.forgeBin} (${binSource}) timeoutMs: ${opts.timeoutMs}\ndispatch: ${dispatchLine}\nprobe: ${probeLine}\n\n## Wired groups\n${groups}\n\n## Verdict summary (last ${recentRuns.length} runs)\n runs=${summary.runs} blocked=${summary.blocked} fail-open=${summary.failOpen} contexts=${summary.contexts}\n\n## Timing per interception point (last ${recentRuns.length} runs)\n${timing}\n\n## Recent runs (last 10)\n${runs}`,
|
|
226
426
|
};
|
|
227
427
|
},
|
|
228
428
|
}), "forge: status command");
|
package/lib/latest.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — "is a newer plugin published?" for /forge-status.
|
|
3
|
+
*
|
|
4
|
+
* Version drift between an installed plugin and the repo is silent: an old
|
|
5
|
+
* install keeps working while missing fixes (the audit found a 1.74.1 install
|
|
6
|
+
* lacking everything since). /forge-status is the one place the user looks when
|
|
7
|
+
* something seems off, so it asks the registry once, with a short timeout, and
|
|
8
|
+
* shows the answer. It is a read-only GET issued only when the user runs the
|
|
9
|
+
* command, never at session start, and every failure (offline, private registry,
|
|
10
|
+
* odd response) degrades to "unknown" — a status command must not throw.
|
|
11
|
+
*
|
|
12
|
+
* 已装插件与仓库的版本漂移是静默的:旧版一直能用,只是缺修复(审计里 1.74.1 的装机就缺了
|
|
13
|
+
* 此后的一切)。/forge-status 是用户觉得不对劲时唯一会看的地方,所以在这里问一次 registry
|
|
14
|
+
* (短超时)并展示结果。只在用户执行该命令时发一次只读 GET,绝不在会话启动时发;任何失败
|
|
15
|
+
* (离线、私有 registry、响应异常)都降级为「未知」——状态命令不能抛错。
|
|
16
|
+
*
|
|
17
|
+
* @module latest
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
21
|
+
const PACKAGE_PATH = "@agent_forge%2Fforge-dsh";
|
|
22
|
+
|
|
23
|
+
/** A published plugin version: plain semver, optionally with a pre-release/build tag, bounded length. */
|
|
24
|
+
const PLAIN_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]{1,40})?$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a string is safe to show as a version. The registry's answer ends up in
|
|
28
|
+
* /forge-status text, which the model can read, so anything that is not plain semver
|
|
29
|
+
* (newlines, escape sequences, prose) is refused rather than echoed.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isPlainVersion(value) {
|
|
35
|
+
return typeof value === "string" && value.length <= 64 && PLAIN_VERSION.test(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compare the numeric core (major.minor.patch) of two versions. A pre-release
|
|
40
|
+
* suffix is ignored; an unparseable input has no order.
|
|
41
|
+
*
|
|
42
|
+
* @param {string|undefined} a
|
|
43
|
+
* @param {string|undefined} b
|
|
44
|
+
* @returns {-1|0|1|undefined} negative when a is older than b.
|
|
45
|
+
*/
|
|
46
|
+
export function compareVersions(a, b) {
|
|
47
|
+
const core = (v) => {
|
|
48
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? ""));
|
|
49
|
+
return m === null ? undefined : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
50
|
+
};
|
|
51
|
+
const left = core(a);
|
|
52
|
+
const right = core(b);
|
|
53
|
+
if (left === undefined || right === undefined) return undefined;
|
|
54
|
+
for (let i = 0; i < 3; i += 1) {
|
|
55
|
+
if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The registry's latest published plugin version, or undefined on any failure.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} [opts]
|
|
64
|
+
* @param {string} [opts.registry="https://registry.npmjs.org"]
|
|
65
|
+
* @param {number} [opts.timeoutMs=3000]
|
|
66
|
+
* @param {typeof fetch} [opts.fetch] - injected for tests; defaults to the global fetch.
|
|
67
|
+
* @param {boolean} [opts.noGlobalFetch] - tests: pretend the runtime has no fetch.
|
|
68
|
+
* @returns {Promise<string|undefined>}
|
|
69
|
+
*/
|
|
70
|
+
export async function fetchLatestVersion(opts = {}) {
|
|
71
|
+
const doFetch = opts.fetch ?? (opts.noGlobalFetch ? undefined : globalThis.fetch);
|
|
72
|
+
if (typeof doFetch !== "function") return undefined;
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
// Not unref'd: the timer is what ends a hung request, and `finally` clears it, so it can keep
|
|
75
|
+
// the process alive for at most timeoutMs. An unref'd timer lets the loop exit mid-request when
|
|
76
|
+
// nothing else is pending.
|
|
77
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 3000);
|
|
78
|
+
try {
|
|
79
|
+
// inside the try: a misconfigured registry value must not make /forge-status throw
|
|
80
|
+
const registry = String(opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
81
|
+
const res = await doFetch(`${registry}/${PACKAGE_PATH}/latest`, { signal: controller.signal });
|
|
82
|
+
if (!res?.ok) return undefined;
|
|
83
|
+
const body = await res.json();
|
|
84
|
+
return isPlainVersion(body?.version) ? body.version : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/messages.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — model-facing message catalogue.
|
|
3
|
+
*
|
|
4
|
+
* Every line the plugin itself puts into the conversation lives here, in both
|
|
5
|
+
* languages, so the wording of one session is uniform (the old mix: English
|
|
6
|
+
* missing-CLI notice, Chinese welcome) and a new message cannot ship in one
|
|
7
|
+
* language only — a test pins that the two catalogues have the same keys.
|
|
8
|
+
* forge's own hook texts are not translated here; they come from the forge binary.
|
|
9
|
+
*
|
|
10
|
+
* Language: config `locale` > env FORGE_DSH_LOCALE > the system locale. A value that
|
|
11
|
+
* names no supported language is skipped; with nothing usable the result is English.
|
|
12
|
+
*
|
|
13
|
+
* 插件自己写进对话的每一句话都集中在此,中英各一份:同一会话措辞统一(此前缺 CLI 提示
|
|
14
|
+
* 是英文、欢迎语是中文),且新增消息不可能只有一种语言——测试钉住两份目录键集合相同。
|
|
15
|
+
* forge 自己 hook 的文案由 forge 二进制输出,不在此翻译。
|
|
16
|
+
* 语言:配置 locale > 环境变量 FORGE_DSH_LOCALE > 系统 locale;不支持的一律回落英文。
|
|
17
|
+
*
|
|
18
|
+
* @module messages
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Supported catalogue languages. */
|
|
22
|
+
const LOCALES = new Set(["en", "zh"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pick the catalogue language.
|
|
26
|
+
*
|
|
27
|
+
* @param {string|undefined} configured - the profile patch's `locale` ("en" | "zh" | "auto").
|
|
28
|
+
* @param {Record<string, string|undefined>} [env] - process.env override (tests).
|
|
29
|
+
* @param {() => string} [systemLocale] - system locale tag source (tests).
|
|
30
|
+
* @returns {"en"|"zh"}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveLocale(
|
|
33
|
+
configured,
|
|
34
|
+
env = process.env,
|
|
35
|
+
systemLocale = () => Intl.DateTimeFormat().resolvedOptions().locale,
|
|
36
|
+
) {
|
|
37
|
+
// Tags are normalized (zh-CN, zh_CN, ZH, zh-Hans all mean zh). A value that names no supported
|
|
38
|
+
// language — a typo, "auto", a language we have no catalogue for — does not decide anything and
|
|
39
|
+
// falls through to the next source, so a bad config line cannot mask FORGE_DSH_LOCALE or the system.
|
|
40
|
+
for (const candidate of [configured, env.FORGE_DSH_LOCALE]) {
|
|
41
|
+
const language = String(candidate ?? "").trim().toLowerCase().replace(/_/g, "-").split("-")[0];
|
|
42
|
+
if (LOCALES.has(language)) return language;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return systemLocale().toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
46
|
+
} catch {
|
|
47
|
+
return "en";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const quoted = (path) => (/\s/.test(path) ? `"${path}"` : path);
|
|
52
|
+
|
|
53
|
+
const en = {
|
|
54
|
+
/** One first-turn line; the user only sees what the model relays, hence the explicit ask. */
|
|
55
|
+
welcome: (pluginVersion, forgeVersion) =>
|
|
56
|
+
`[forge-dsh] Forge quality gates now govern this project (plugin ${pluginVersion}${forgeVersion === undefined ? "" : `, forge ${forgeVersion}`}): ` +
|
|
57
|
+
"create a forge task before editing source, and dangerous Bash commands are intercepted. " +
|
|
58
|
+
"In your first reply, tell the user this in one sentence and mention /forge-status for status and `forge off` to switch the gates off. Say it only once.",
|
|
59
|
+
/** The bundled binary is not on PATH while forge's gate messages say `forge task …`. */
|
|
60
|
+
bundledHint: (binPath) =>
|
|
61
|
+
`[forge-dsh] forge CLI bundled with this plugin: ${quoted(binPath)}. It may not be on PATH — ` +
|
|
62
|
+
"when a gate message or the protocol tells you to run a `forge …` command, " +
|
|
63
|
+
"run it with this path instead of `forge`.",
|
|
64
|
+
/** Installing writes the user's home (~/.agents/skills), hence "after the user agrees". */
|
|
65
|
+
skillsHint: (forgeBin) =>
|
|
66
|
+
"[forge-dsh] Forge's skill library is not installed for DSH (~/.agents/skills is empty), so the skills forge hooks point to are unavailable. " +
|
|
67
|
+
"Offer to install it, and run `" + quoted(forgeBin) + " skills install --global --target agents` only after the user agrees.",
|
|
68
|
+
missingForge: (forgeBin, error) =>
|
|
69
|
+
`[forge-dsh] forge CLI not found (forgeBin: ${forgeBin}). ` +
|
|
70
|
+
"Forge gates are OFF for this session — every action runs unguarded (fail-open). " +
|
|
71
|
+
"Reinstall the plugin (it bundles forge) or run `npm install -g @agent_forge/forge`, then start a new session; " +
|
|
72
|
+
"if it lives elsewhere, set forgeBin in the profile patch." +
|
|
73
|
+
(error === undefined ? "" : `\nProbe error: ${error}`),
|
|
74
|
+
degraded: (count) =>
|
|
75
|
+
`[forge-dsh] ${count} forge hook runs failed open in this session (a timeout or an unreadable forge reply), so those checks did not run. ` +
|
|
76
|
+
"Run /forge-status to see which, and raise timeoutMs if a gate is timing out. This is advisory; nothing is blocked.",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const zh = {
|
|
80
|
+
welcome: (pluginVersion, forgeVersion) =>
|
|
81
|
+
`[forge-dsh] Forge 质量门禁已接管本项目(插件 ${pluginVersion}${forgeVersion === undefined ? "" : `,forge ${forgeVersion}`}):修改源码前需先建 forge task,危险的 Bash 命令会被拦截。` +
|
|
82
|
+
"请在本会话第一次回复的末尾用一句话告知用户这一点,并提示:/forge-status 查看状态,forge off 关闭门禁。只提这一次。",
|
|
83
|
+
bundledHint: (binPath) =>
|
|
84
|
+
`[forge-dsh] 本插件自带的 forge CLI:${quoted(binPath)}。它可能不在 PATH 上——` +
|
|
85
|
+
"当门禁消息或协议要求你运行 `forge …` 命令时,请用这个路径代替 `forge`。",
|
|
86
|
+
skillsHint: (forgeBin) =>
|
|
87
|
+
"[forge-dsh] 尚未为 DSH 安装 Forge 技能库(~/.agents/skills 为空),forge hook 提示的 skill 因此不可用。" +
|
|
88
|
+
"请向用户提议安装,征得用户同意后再运行 `" + quoted(forgeBin) + " skills install --global --target agents`。",
|
|
89
|
+
missingForge: (forgeBin, error) =>
|
|
90
|
+
`[forge-dsh] 未找到 forge CLI(forgeBin:${forgeBin})。` +
|
|
91
|
+
"本会话 Forge 门禁处于关闭状态——所有操作都不受保护(fail-open)。" +
|
|
92
|
+
"请重装本插件(它自带 forge)或运行 `npm install -g @agent_forge/forge`,然后开启新会话;" +
|
|
93
|
+
"如果 forge 在别处,请在 profile patch 里设置 forgeBin。" +
|
|
94
|
+
(error === undefined ? "" : `\n探测错误:${error}`),
|
|
95
|
+
degraded: (count) =>
|
|
96
|
+
`[forge-dsh] 本会话有 ${count} 次 forge hook 运行 fail-open(超时或 forge 回复无法解析),这些检查没有执行。` +
|
|
97
|
+
"运行 /forge-status 查看是哪些,若某个门禁超时请调大 timeoutMs。这只是提示,不会阻断任何操作。",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const CATALOGUES = { en, zh };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The message catalogue for a language; unknown languages get English.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} locale
|
|
106
|
+
* @returns {typeof en}
|
|
107
|
+
*/
|
|
108
|
+
export function messagesFor(locale) {
|
|
109
|
+
return CATALOGUES[locale] ?? en;
|
|
110
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -27,6 +27,7 @@ import { spawn } from "node:child_process";
|
|
|
27
27
|
* @property {string} [reason] - block reason (shown to the model).
|
|
28
28
|
* @property {string} [context] - allow-path additionalContext to inject.
|
|
29
29
|
* @property {string} [error] - infrastructure failure note (fail-open path).
|
|
30
|
+
* @property {boolean} [timedOut] - the failure was the ceiling kill (not a forge error).
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -39,7 +40,8 @@ import { spawn } from "node:child_process";
|
|
|
39
40
|
* and EVERY gate silently fails open. cmd.exe resolves forge.cmd via PATHEXT
|
|
40
41
|
* (and a bare forge.exe directly), so win32 routes through it. argv holds
|
|
41
42
|
* spec constants only (safe charset). The binary is operator config and is
|
|
42
|
-
* quoted, never escaped — see the metacharacter note inside planSpawn.
|
|
43
|
+
* quoted, never escaped — see the metacharacter note inside planSpawn. Arguments
|
|
44
|
+
* with a cmd metacharacter (a batch matcher such as Write|Edit) are quoted too.
|
|
43
45
|
*
|
|
44
46
|
* @param {string} forgeBin - binary name/path from config.
|
|
45
47
|
* @param {string[]} argv - hook arguments (spec constants).
|
|
@@ -56,9 +58,11 @@ export function planSpawn(forgeBin, argv, platform = process.platform) {
|
|
|
56
58
|
// %VAR% expansion (cmd expands it even inside quotes) and ! with delayed
|
|
57
59
|
// expansion (off by default). Both are accepted residual risk because
|
|
58
60
|
// forgeBin is trusted operator config — the same trust level as PATH itself.
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const quote = (token) => (/[&|()<>^,;=\s]/.test(token) && !token.includes('"') ? `"${token}"` : token);
|
|
62
|
+
// Arguments get the same treatment: a batch matcher like "Write|Edit" would
|
|
63
|
+
// otherwise be parsed by cmd.exe as a pipe. Spec-constant arguments (hook
|
|
64
|
+
// names, flags) carry no metacharacter and pass through unchanged.
|
|
65
|
+
return { file: quote(forgeBin), args: argv.map(quote), shell: true };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -73,10 +77,37 @@ export function planSpawn(forgeBin, argv, platform = process.platform) {
|
|
|
73
77
|
* @returns {Promise<HookVerdict>}
|
|
74
78
|
*/
|
|
75
79
|
export function runForgeHook(command, payload, opts = {}) {
|
|
80
|
+
const parts = command.split(" "); // ["forge","hook","task-guard"]
|
|
81
|
+
return runForge(parts.slice(1), payload, opts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run one whole matcher group in a single forge process:
|
|
86
|
+
* `forge hook batch --event <E> [--matcher <M>]`. forge runs the group's roster
|
|
87
|
+
* in order, stops at the first block, and prints that hook's decision JSON to
|
|
88
|
+
* stdout (same contract as a single hook); allow-path contexts come back merged
|
|
89
|
+
* into one document. One spawn instead of N saves the per-process start-up cost on
|
|
90
|
+
* every tool call. Forges from before the stdout fix print nothing on a block —
|
|
91
|
+
* decisions.runMatchedGroups detects that and falls back to per-hook runs.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} event - forge event name (PreToolUse, Stop, …).
|
|
94
|
+
* @param {string} matcher - the spec group's matcher; "" selects the event's
|
|
95
|
+
* matcher-less group.
|
|
96
|
+
* @param {object} payload - Claude-Code-shape hook stdin payload.
|
|
97
|
+
* @param {object} [opts] - same as runForgeHook.
|
|
98
|
+
* @returns {Promise<HookVerdict>}
|
|
99
|
+
*/
|
|
100
|
+
export function runForgeBatch(event, matcher, payload, opts = {}) {
|
|
101
|
+
const argv = ["hook", "batch", "--event", event];
|
|
102
|
+
if (matcher !== "") argv.push("--matcher", matcher);
|
|
103
|
+
return runForge(argv, payload, opts);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Spawn forge with `argv`, feed the payload, and fold stdout into a verdict. */
|
|
107
|
+
function runForge(argv, payload, opts) {
|
|
76
108
|
const forgeBin = opts.forgeBin ?? "forge";
|
|
77
109
|
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
78
|
-
const
|
|
79
|
-
const plan = planSpawn(forgeBin, parts.slice(1));
|
|
110
|
+
const plan = planSpawn(forgeBin, argv);
|
|
80
111
|
return new Promise((resolve) => {
|
|
81
112
|
let child;
|
|
82
113
|
try {
|
|
@@ -109,7 +140,7 @@ export function runForgeHook(command, payload, opts = {}) {
|
|
|
109
140
|
if (settled) return;
|
|
110
141
|
settled = true;
|
|
111
142
|
killTree(child, plan.shell);
|
|
112
|
-
resolve({ block: false, error: `timeout after ${timeoutMs}ms
|
|
143
|
+
resolve({ block: false, error: `timeout after ${timeoutMs}ms`, timedOut: true });
|
|
113
144
|
}, timeoutMs);
|
|
114
145
|
const settle = (verdict) => {
|
|
115
146
|
if (settled) return;
|
package/lib/skills.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — is Forge's skill library installed for DSH?
|
|
3
|
+
*
|
|
4
|
+
* Installing the plugin delivers the bridge and (via its dependency) the forge
|
|
5
|
+
* CLI, but not the skill library: DSH reads the agent-neutral `~/.agents/skills`
|
|
6
|
+
* directory, which only `forge skills install --global --target agents` fills.
|
|
7
|
+
* Without it a DSH user silently gets half the product, so the first-turn
|
|
8
|
+
* onboarding offers the install when the directory is empty.
|
|
9
|
+
*
|
|
10
|
+
* 装插件交付的是桥接层和(经依赖)forge CLI,不含技能库:DSH 读跨 agent 的
|
|
11
|
+
* `~/.agents/skills`,只有 `forge skills install --global --target agents` 会填充它。
|
|
12
|
+
* 缺了它 DSH 用户悄悄只拿到半个产品,所以目录为空时首轮引导会提议安装。
|
|
13
|
+
*
|
|
14
|
+
* @module skills
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether `<home>/.agents/skills` holds at least one entry (a link or a copy).
|
|
22
|
+
* A missing directory counts as not installed. Any other failure — an unreadable
|
|
23
|
+
* or odd location — degrades to `true`: this only gates a polite nudge, and
|
|
24
|
+
* nagging on an error the plugin cannot interpret would be worse than staying quiet.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} [home] - home directory (tests); defaults to the user's.
|
|
27
|
+
* @param {(dir: string) => string[]} [readdir] - directory reader (tests); defaults to fs.readdirSync.
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function agentsSkillsInstalled(home = homedir(), readdir = readdirSync) {
|
|
31
|
+
if (typeof home !== "string" || home === "") return true;
|
|
32
|
+
try {
|
|
33
|
+
// dotfiles (.DS_Store …) are not skills
|
|
34
|
+
return readdir(join(home, ".agents", "skills")).some((name) => !name.startsWith("."));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return error?.code !== "ENOENT";
|
|
37
|
+
}
|
|
38
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -89,6 +89,27 @@ export function matchedCommands(groups, ccName) {
|
|
|
89
89
|
return commands;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The spec groups of one event whose matcher accepts ccName, as
|
|
94
|
+
* `{matcher, commands}` in spec order. The matcher is what `forge hook batch
|
|
95
|
+
* --matcher` selects on ("" = the event's matcher-less group); `commands` is the
|
|
96
|
+
* same group's roster, kept for the serial fallback. Matching semantics are
|
|
97
|
+
* identical to matchedCommands.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<{matcher?: string, hooks: Array<{command: string}>}>} groups
|
|
100
|
+
* @param {string} ccName
|
|
101
|
+
* @returns {Array<{matcher: string, commands: string[]}>}
|
|
102
|
+
*/
|
|
103
|
+
export function matchedGroups(groups, ccName) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const group of groups ?? []) {
|
|
106
|
+
const matcher = group.matcher ?? "";
|
|
107
|
+
if (matcher !== "" && !new RegExp(`^(?:${matcher})$`).test(ccName)) continue;
|
|
108
|
+
out.push({ matcher, commands: (group.hooks ?? []).map((hook) => hook.command) });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
/**
|
|
93
114
|
* Extract the session identity forge expects from a DSH agent handle.
|
|
94
115
|
* Falls back to process cwd and an empty session id (forge degrades to its
|
|
@@ -196,3 +217,28 @@ export function pluginMessage(text) {
|
|
|
196
217
|
source: { kind: "plugin:forge-quality" },
|
|
197
218
|
};
|
|
198
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The marker forge's own SessionStart hook (task-resume) prints when the
|
|
223
|
+
* project is managed — the same mechanical signal the user-level instruction
|
|
224
|
+
* block keys on (internal/skillgen/claudemd.go). The welcome line is only
|
|
225
|
+
* truthful when it appeared, so the plugin waits for it instead of guessing from
|
|
226
|
+
* "forge --version runs".
|
|
227
|
+
*
|
|
228
|
+
* forge 自己的 SessionStart hook 在项目被接管时输出的横幅标记,与用户级指令段的
|
|
229
|
+
* 激活判据同源;欢迎语只在它出现时才属实。
|
|
230
|
+
*/
|
|
231
|
+
export const MANAGED_BANNER = "[forge-session]";
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether a DSH agent handle is a delegated child (its session header carries
|
|
235
|
+
* origin 'subagent' / a parentSession). Children report to their parent, not to
|
|
236
|
+
* the user, so they must not be told to greet anyone.
|
|
237
|
+
*
|
|
238
|
+
* @param {object|undefined} agent
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
export function isSubagent(agent) {
|
|
242
|
+
const header = agent?.session?.header;
|
|
243
|
+
return header?.origin === "subagent" || header?.parentSession !== undefined;
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent_forge/forge-dsh",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.0",
|
|
4
4
|
"description": "Forge quality gates for DeepSeek Harness (dsh): task gates, read-before-edit, bash hazard interception and quality scoring, driven by the forge CLI through DSH's typed interception points.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"lib/index.js",
|
|
15
15
|
"lib/runner.js",
|
|
16
|
+
"lib/bundled.js",
|
|
17
|
+
"lib/messages.js",
|
|
18
|
+
"lib/skills.js",
|
|
19
|
+
"lib/latest.js",
|
|
16
20
|
"lib/decisions.js",
|
|
17
21
|
"lib/tools.js",
|
|
18
22
|
"lib/spec.json",
|
|
@@ -35,6 +39,9 @@
|
|
|
35
39
|
"ai-code-quality"
|
|
36
40
|
],
|
|
37
41
|
"license": "Apache-2.0",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@agent_forge/forge": "1.83.0"
|
|
44
|
+
},
|
|
38
45
|
"engines": {
|
|
39
46
|
"node": ">=18"
|
|
40
47
|
},
|