@agent_forge/forge-dsh 1.80.1 → 1.82.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 +29 -15
- package/lib/bundled.js +87 -0
- package/lib/decisions.js +22 -8
- package/lib/index.js +147 -32
- package/lib/tools.js +45 -0
- package/package.json +5 -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,10 @@ 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)
|
|
89
103
|
enabled: true # false unwires every listener
|
|
90
104
|
debug: false # log fail-open hook errors to console.error
|
|
91
105
|
```
|
|
@@ -101,8 +115,8 @@ design; surface them with `/forge-status` or `debug:true`.
|
|
|
101
115
|
|
|
102
116
|
A missing `forge` binary is the one exception to that silence: each session start
|
|
103
117
|
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
|
|
118
|
+
for the process lifetime) and, when the probe fails, queues a visible notice
|
|
119
|
+
(delivered with the first pre-step) naming the configured `forgeBin`. Without it an uninstalled CLI would look exactly
|
|
106
120
|
like a healthy plugin gating nothing. The notice is informational, not a switch —
|
|
107
121
|
while the binary is missing, every hook attempt fails open on its own, so gates are
|
|
108
122
|
inactive whether or not the probe ran. Installing forge needs no host restart: the
|
|
@@ -131,7 +145,7 @@ session start.
|
|
|
131
145
|
## Development
|
|
132
146
|
|
|
133
147
|
```sh
|
|
134
|
-
npm install #
|
|
148
|
+
npm install --no-package-lock # @deepseek-ai/cordis for the wiring tests (+ the bundled forge, unused by them)
|
|
135
149
|
npm test # mapping, runner (incl. cross-OS planSpawn units), decision folding, real-cordis wiring
|
|
136
150
|
```
|
|
137
151
|
|
package/lib/bundled.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One line telling the agent where forge lives. The bundled binary is not on
|
|
74
|
+
* PATH, while forge's own gate messages say `forge task start …`; gates are
|
|
75
|
+
* driven by the agent, so it needs the absolute path to run them.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} binPath - absolute path from resolveBundledForge.
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function bundledForgeHint(binPath) {
|
|
81
|
+
const shown = /\s/.test(binPath) ? `"${binPath}"` : binPath;
|
|
82
|
+
return (
|
|
83
|
+
`[forge-dsh] forge CLI bundled with this plugin: ${shown}. It may not be on PATH — ` +
|
|
84
|
+
"when a gate message or the protocol tells you to run a `forge …` command, " +
|
|
85
|
+
`run it with this path instead of \`forge\`.`
|
|
86
|
+
);
|
|
87
|
+
}
|
package/lib/decisions.js
CHANGED
|
@@ -158,16 +158,30 @@ export async function turnStoppingOutcome(outcome, agent) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
|
-
* agent/session-start (emit)
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* the same
|
|
161
|
+
* agent/session-start (emit) contexts ride the NEXT pre-step instead of
|
|
162
|
+
* agent.inject. Emit listeners are detached and inject at session start was
|
|
163
|
+
* observed to drop the contexts silently (audit 2026-10-20: all four lost,
|
|
164
|
+
* not delayed, while pre-step messages in the same session were delivered), so
|
|
165
|
+
* the queued strings are prepended into the first `enter` decision. A reject
|
|
166
|
+
* (or any non-enter decision) leaves the queue to the caller — it must retain
|
|
167
|
+
* the strings for the next pre-step.
|
|
165
168
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
169
|
+
* 会话启动的上下文改走下一次 pre-step 的 enter 决策(而非 agent.inject):
|
|
170
|
+
* 实测 inject 在会话启动时静默丢弃,pre-step 消息则稳定送达。
|
|
171
|
+
*
|
|
172
|
+
* @param {object} decision - PreStepDecision (after preStepDecision folding).
|
|
173
|
+
* @param {string[]} pending - queued session-start contexts, delivery order.
|
|
174
|
+
* @returns {{decision: object, delivered: boolean}} delivered=true means the
|
|
175
|
+
* caller may clear the queue.
|
|
168
176
|
*/
|
|
169
|
-
export function
|
|
170
|
-
|
|
177
|
+
export function prependStartContexts(decision, pending) {
|
|
178
|
+
if (pending.length === 0 || decision?.kind !== "enter") {
|
|
179
|
+
return { decision, delivered: false };
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
decision: { ...decision, messages: [...pending.map(pluginMessage), ...(decision.messages ?? [])] },
|
|
183
|
+
delivered: true,
|
|
184
|
+
};
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
/** 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,26 @@
|
|
|
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
|
+
* welcome - false drops the one-line first-turn onboarding (default true)
|
|
29
|
+
* startWaitMs - how long the first pre-step waits for the session-start
|
|
30
|
+
* contexts before moving on without them (default 1500)
|
|
25
31
|
* enabled - false disables every listener (default true)
|
|
26
32
|
* debug - log hook failures to console.error (default false)
|
|
27
33
|
*
|
|
28
34
|
* @module @agent_forge/forge-dsh
|
|
29
35
|
*/
|
|
30
36
|
import { readFileSync } from "node:fs";
|
|
37
|
+
import { bundledForgeHint, resolveBundledForge } from "./bundled.js";
|
|
31
38
|
import { probeForge } from "./runner.js";
|
|
32
|
-
import { runHookGroup, preExecuteDecision, postExecuteDecision, preStepDecision,
|
|
33
|
-
import { buildEventPayload, buildToolPayload, matchedCommands,
|
|
39
|
+
import { runHookGroup, preExecuteDecision, postExecuteDecision, preStepDecision, prependStartContexts, turnStoppingOutcome } from "./decisions.js";
|
|
40
|
+
import { buildEventPayload, buildToolPayload, matchedCommands, promptText, toCCToolName, welcomeText, isSubagent, MANAGED_BANNER } from "./tools.js";
|
|
34
41
|
|
|
35
42
|
const spec = JSON.parse(readFileSync(new URL("./spec.json", import.meta.url), "utf8"));
|
|
43
|
+
const pluginVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
36
44
|
|
|
37
45
|
/** Stable Cordis plugin name. */
|
|
38
46
|
const name = "forge-quality";
|
|
@@ -45,11 +53,23 @@ const inject = ["tools"];
|
|
|
45
53
|
*/
|
|
46
54
|
function apply(ctx, config = {}) {
|
|
47
55
|
if (config.enabled === false) return;
|
|
56
|
+
const explicitBin = config.forgeBin ?? process.env.FORGE_BIN;
|
|
57
|
+
let bundledBin;
|
|
58
|
+
if (explicitBin === undefined) {
|
|
59
|
+
try {
|
|
60
|
+
bundledBin = (config.resolveBundledForge ?? resolveBundledForge)(); // config seam: tests
|
|
61
|
+
} catch {
|
|
62
|
+
bundledBin = undefined; // an unresolvable bundle must never break plugin load
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let binSource = explicitBin !== undefined ? "configured" : bundledBin !== undefined ? "bundled" : "PATH";
|
|
48
66
|
const opts = {
|
|
49
|
-
forgeBin:
|
|
67
|
+
forgeBin: explicitBin ?? bundledBin ?? "forge",
|
|
50
68
|
timeoutMs: config.timeoutMs ?? 30000,
|
|
51
69
|
};
|
|
52
70
|
const debug = config.debug === true;
|
|
71
|
+
const welcome = config.welcome !== false;
|
|
72
|
+
const startWaitMs = Number.isFinite(config.startWaitMs) && config.startWaitMs >= 0 ? config.startWaitMs : 1500;
|
|
53
73
|
// Recent-run ring buffer for /forge-status — fail-open verdicts are silent
|
|
54
74
|
// by design, so this (plus debug logging) is the only way to see them.
|
|
55
75
|
const recentRuns = [];
|
|
@@ -67,21 +87,60 @@ function apply(ctx, config = {}) {
|
|
|
67
87
|
}
|
|
68
88
|
};
|
|
69
89
|
|
|
90
|
+
// Session-start contexts (forge's own onboarding + the missing-forge notice)
|
|
91
|
+
// are queued per agent and prepended into that agent's next pre-step.
|
|
92
|
+
// agent.inject at session start was observed to drop them silently, while
|
|
93
|
+
// pre-step messages in the same session were delivered. The session-start
|
|
94
|
+
// run is detached and takes a few hundred ms, so the first pre-step may
|
|
95
|
+
// arrive before it finishes: `ready` lets that pre-step wait a bounded time
|
|
96
|
+
// (startWaitMs) instead of pushing the onboarding to turn 2.
|
|
97
|
+
// Keyed by session id when the handle has one (the host may hand each event a
|
|
98
|
+
// fresh wrapper object for the same session), by object identity otherwise.
|
|
99
|
+
const startStates = new WeakMap();
|
|
100
|
+
const startStatesById = new Map();
|
|
101
|
+
const startStateOf = (agent) => {
|
|
102
|
+
if (agent === null || typeof agent !== "object") return undefined;
|
|
103
|
+
const id = agent.session?.id;
|
|
104
|
+
const byId = typeof id === "string" && id !== "";
|
|
105
|
+
let state = byId ? startStatesById.get(id) : startStates.get(agent);
|
|
106
|
+
if (state === undefined) {
|
|
107
|
+
state = { pending: [], ready: Promise.resolve(), waited: true };
|
|
108
|
+
if (byId) startStatesById.set(id, state);
|
|
109
|
+
else startStates.set(agent, state);
|
|
110
|
+
}
|
|
111
|
+
return state;
|
|
112
|
+
};
|
|
113
|
+
// Only the first pre-step after a session-start waits: a slow start run must
|
|
114
|
+
// not tax every later step by startWaitMs; its contexts simply land on the
|
|
115
|
+
// first enter after it finishes.
|
|
116
|
+
const waitStart = async (state) => {
|
|
117
|
+
if (state === undefined || state.waited) return;
|
|
118
|
+
state.waited = true;
|
|
119
|
+
let timer;
|
|
120
|
+
const timeout = new Promise((resolve) => {
|
|
121
|
+
timer = setTimeout(resolve, startWaitMs);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
await Promise.race([state.ready, timeout]);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
70
131
|
// A missing forge binary used to be completely silent: every hook failed
|
|
71
132
|
// open, so the plugin looked installed but gated nothing. Probe once per
|
|
72
133
|
// 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.
|
|
134
|
+
// host restart) and surface the failure through the same start queue. Once a
|
|
135
|
+
// probe succeeds the answer is cached for the process lifetime; forgeBin is
|
|
136
|
+
// static config per plugin instance, so it cannot go stale.
|
|
77
137
|
let forgeReady = false;
|
|
78
138
|
let lastProbe;
|
|
79
139
|
let probeInFlight;
|
|
80
|
-
const noticedAgents = new WeakSet();
|
|
81
140
|
const missingForgeNotice = (error) =>
|
|
82
141
|
`[forge-dsh] forge CLI not found (forgeBin: ${opts.forgeBin}). ` +
|
|
83
142
|
"Forge gates are OFF for this session — every action runs unguarded (fail-open). " +
|
|
84
|
-
"
|
|
143
|
+
"Reinstall the plugin (it bundles forge) or run `npm install -g @agent_forge/forge`, then start a new session; " +
|
|
85
144
|
"if it lives elsewhere, set forgeBin in the profile patch." +
|
|
86
145
|
(error === undefined ? "" : `\nProbe error: ${error}`);
|
|
87
146
|
const ensureForge = async (agent) => {
|
|
@@ -92,20 +151,28 @@ function apply(ctx, config = {}) {
|
|
|
92
151
|
probeInFlight = undefined;
|
|
93
152
|
});
|
|
94
153
|
}
|
|
95
|
-
|
|
154
|
+
let probe = await probeInFlight;
|
|
155
|
+
// A bundled binary that resolved but will not run (wrong arch, lost exec bit,
|
|
156
|
+
// truncated download) must not void the gates when a working forge is on PATH.
|
|
157
|
+
if (!probe.ok && binSource === "bundled") {
|
|
158
|
+
const onPath = await probeForge({ forgeBin: "forge", timeoutMs: Math.min(opts.timeoutMs, 5000) });
|
|
159
|
+
if (onPath.ok) {
|
|
160
|
+
if (debug) console.error(`[forge-dsh] bundled forge unusable (${probe.error}); using PATH forge`);
|
|
161
|
+
opts.forgeBin = "forge";
|
|
162
|
+
binSource = "PATH";
|
|
163
|
+
probe = onPath;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
96
166
|
lastProbe = probe;
|
|
97
167
|
if (probe.ok) {
|
|
98
168
|
forgeReady = true;
|
|
99
169
|
return true;
|
|
100
170
|
}
|
|
101
171
|
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
|
-
}
|
|
172
|
+
const state = startStateOf(agent);
|
|
173
|
+
if (state !== undefined && !state.noticed) {
|
|
174
|
+
state.noticed = true;
|
|
175
|
+
state.pending.push(missingForgeNotice(probe.error));
|
|
109
176
|
}
|
|
110
177
|
return false;
|
|
111
178
|
};
|
|
@@ -130,30 +197,73 @@ function apply(ctx, config = {}) {
|
|
|
130
197
|
|
|
131
198
|
// UserPromptSubmit — the prompt rides the step's claimed messages.
|
|
132
199
|
ctx.effect(() => ctx.on("agent/pre-step", async (payload, next) => {
|
|
200
|
+
const state = startStateOf(payload?.agent);
|
|
201
|
+
const startWait = waitStart(state);
|
|
133
202
|
const commands = matchedCommands(spec.UserPromptSubmit, "");
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
let decision;
|
|
204
|
+
if (commands.length === 0) {
|
|
205
|
+
decision = await next();
|
|
206
|
+
} else {
|
|
207
|
+
const eventPayload = buildEventPayload("UserPromptSubmit", payload?.agent, {
|
|
208
|
+
prompt: promptText(payload?.messages),
|
|
209
|
+
});
|
|
210
|
+
const outcome = await runHookGroup(commands, eventPayload, opts);
|
|
211
|
+
noteErrors("pre-step", outcome);
|
|
212
|
+
decision = await preStepDecision(outcome, next, payload?.agent);
|
|
213
|
+
}
|
|
214
|
+
await startWait;
|
|
215
|
+
if (state === undefined) return decision;
|
|
216
|
+
// a reject keeps the queue for the next pre-step; only an enter clears it
|
|
217
|
+
const delivery = prependStartContexts(decision, state.pending);
|
|
218
|
+
if (delivery.delivered) state.pending.length = 0;
|
|
219
|
+
return delivery.decision;
|
|
141
220
|
}), "forge: pre-step");
|
|
142
221
|
|
|
143
|
-
// SessionStart — emit mode: detached, contexts queued
|
|
222
|
+
// SessionStart — emit mode: detached, contexts queued per agent and
|
|
223
|
+
// delivered by the next pre-step (see startStates above).
|
|
144
224
|
// source 'compact' doubles as PostCompact (DSH exposes no dedicated point).
|
|
145
225
|
ctx.effect(() => ctx.on("agent/session-start", (payload) => {
|
|
146
226
|
const agent = payload?.agent;
|
|
147
227
|
const source = payload?.source;
|
|
148
|
-
|
|
149
|
-
|
|
228
|
+
const state = startStateOf(agent);
|
|
229
|
+
const queue = (outcome) => {
|
|
230
|
+
if (state !== undefined) {
|
|
231
|
+
// overlapping starts must not stack the same onboarding twice
|
|
232
|
+
for (const text of outcome.contexts) if (!state.pending.includes(text)) state.pending.push(text);
|
|
233
|
+
} else if (debug && outcome.contexts.length > 0) {
|
|
234
|
+
console.error("[forge-dsh] session-start contexts dropped: no agent handle to queue them on");
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const run = (async () => {
|
|
238
|
+
const forgeOk = await ensureForge(agent);
|
|
239
|
+
// The bundled binary is not on PATH while forge's gate messages say
|
|
240
|
+
// `forge task …`, so the agent is told where it is. After a compaction the
|
|
241
|
+
// earlier hint is summarised away, so it is re-sent.
|
|
242
|
+
if (source === "compact" && state !== undefined) state.hinted = false;
|
|
243
|
+
if (forgeOk && binSource === "bundled" && state !== undefined && !state.hinted) {
|
|
244
|
+
state.hinted = true;
|
|
245
|
+
state.pending.push(bundledForgeHint(opts.forgeBin));
|
|
246
|
+
}
|
|
150
247
|
const start = await runHookGroup(
|
|
151
248
|
matchedCommands(spec.SessionStart, ""),
|
|
152
249
|
buildEventPayload("SessionStart", agent, { source }),
|
|
153
250
|
opts,
|
|
154
251
|
);
|
|
155
252
|
noteErrors("session-start", start);
|
|
156
|
-
|
|
253
|
+
queue(start);
|
|
254
|
+
// Welcome — queue order: welcome, path hint, forge's own contexts. One
|
|
255
|
+
// human-facing line per session: startup only (resume/compact are not a
|
|
256
|
+
// first meeting), never for delegated children (they report to their
|
|
257
|
+
// parent), and only when forge itself announced the project as managed —
|
|
258
|
+
// a working binary alone does not mean the gates apply here (no `forge
|
|
259
|
+
// init`, or `forge off`).
|
|
260
|
+
const isStartup = source === undefined || source === "startup";
|
|
261
|
+
const managed = start.contexts.some((text) => text.includes(MANAGED_BANNER));
|
|
262
|
+
if (forgeOk && welcome && isStartup && managed && !isSubagent(agent) && state !== undefined && !state.welcomed) {
|
|
263
|
+
state.welcomed = true;
|
|
264
|
+
const forgeVersion = (lastProbe?.version?.match(/\d+\.\d+\.\d+/) ?? [])[0];
|
|
265
|
+
state.pending.unshift(welcomeText(pluginVersion, forgeVersion));
|
|
266
|
+
}
|
|
157
267
|
if (source === "compact") {
|
|
158
268
|
const compact = await runHookGroup(
|
|
159
269
|
matchedCommands(spec.PostCompact, ""),
|
|
@@ -161,11 +271,16 @@ function apply(ctx, config = {}) {
|
|
|
161
271
|
opts,
|
|
162
272
|
);
|
|
163
273
|
noteErrors("post-compact", compact);
|
|
164
|
-
|
|
274
|
+
queue(compact);
|
|
165
275
|
}
|
|
166
276
|
})().catch((error) => {
|
|
167
277
|
if (debug) console.error(`[forge-dsh] session-start failed open: ${error}`);
|
|
168
278
|
});
|
|
279
|
+
// overlapping session-starts (startup then compact) both gate the pre-step
|
|
280
|
+
if (state !== undefined) {
|
|
281
|
+
state.waited = false;
|
|
282
|
+
state.ready = Promise.all([state.ready, run]);
|
|
283
|
+
}
|
|
169
284
|
}), "forge: session-start");
|
|
170
285
|
|
|
171
286
|
// Stop — a blocking gate steers the agent into another step.
|
|
@@ -222,7 +337,7 @@ function apply(ctx, config = {}) {
|
|
|
222
337
|
: `FAILED — gates inactive (${lastProbe.error})`;
|
|
223
338
|
return {
|
|
224
339
|
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}`,
|
|
340
|
+
text: `# Forge quality gates\n\nforgeBin: ${opts.forgeBin} (${binSource}) 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}`,
|
|
226
341
|
};
|
|
227
342
|
},
|
|
228
343
|
}), "forge: status command");
|
package/lib/tools.js
CHANGED
|
@@ -196,3 +196,48 @@ export function pluginMessage(text) {
|
|
|
196
196
|
source: { kind: "plugin:forge-quality" },
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The marker forge's own SessionStart hook (task-resume) prints when the
|
|
202
|
+
* project is managed — the same mechanical signal the user-level instruction
|
|
203
|
+
* block keys on (internal/skillgen/claudemd.go). The welcome line is only
|
|
204
|
+
* truthful when it appeared, so the plugin waits for it instead of guessing from
|
|
205
|
+
* "forge --version runs".
|
|
206
|
+
*
|
|
207
|
+
* forge 自己的 SessionStart hook 在项目被接管时输出的横幅标记,与用户级指令段的
|
|
208
|
+
* 激活判据同源;欢迎语只在它出现时才属实。
|
|
209
|
+
*/
|
|
210
|
+
export const MANAGED_BANNER = "[forge-session]";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Whether a DSH agent handle is a delegated child (its session header carries
|
|
214
|
+
* origin 'subagent' / a parentSession). Children report to their parent, not to
|
|
215
|
+
* the user, so they must not be told to greet anyone.
|
|
216
|
+
*
|
|
217
|
+
* @param {object|undefined} agent
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
export function isSubagent(agent) {
|
|
221
|
+
const header = agent?.session?.header;
|
|
222
|
+
return header?.origin === "subagent" || header?.parentSession !== undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* First-turn onboarding line (model-facing; the user only sees what the model
|
|
227
|
+
* relays, so it asks for one sentence in the first reply). Carries the plugin
|
|
228
|
+
* and forge versions so a stale install is visible at a glance.
|
|
229
|
+
*
|
|
230
|
+
* 首轮引导:模型可见、用户只能通过模型复述看到,所以明确要求在第一次回复里
|
|
231
|
+
* 用一句话告知用户;带插件与 forge 版本,装旧版一眼可见。
|
|
232
|
+
*
|
|
233
|
+
* @param {string} pluginVersion
|
|
234
|
+
* @param {string|undefined} forgeVersion
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
export function welcomeText(pluginVersion, forgeVersion) {
|
|
238
|
+
const versions = `插件 ${pluginVersion}${forgeVersion === undefined ? "" : `,forge ${forgeVersion}`}`;
|
|
239
|
+
return (
|
|
240
|
+
`[forge-dsh] Forge 质量门禁已接管本项目(${versions}):修改源码前需先建 forge task,危险的 Bash 命令会被拦截。` +
|
|
241
|
+
"请在本会话第一次回复的末尾用一句话告知用户这一点,并提示:/forge-status 查看状态,forge off 关闭门禁。只提这一次。"
|
|
242
|
+
);
|
|
243
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent_forge/forge-dsh",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.82.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,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"lib/index.js",
|
|
15
15
|
"lib/runner.js",
|
|
16
|
+
"lib/bundled.js",
|
|
16
17
|
"lib/decisions.js",
|
|
17
18
|
"lib/tools.js",
|
|
18
19
|
"lib/spec.json",
|
|
@@ -35,6 +36,9 @@
|
|
|
35
36
|
"ai-code-quality"
|
|
36
37
|
],
|
|
37
38
|
"license": "Apache-2.0",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@agent_forge/forge": "1.82.0"
|
|
41
|
+
},
|
|
38
42
|
"engines": {
|
|
39
43
|
"node": ">=18"
|
|
40
44
|
},
|