@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 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 via `agent.inject()` |
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 on `PATH` (`npm install -g @agent_forge/forge`), with the project
47
- initialized (`forge init`) for task gates to have state to enforce.
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: npm lays out `forge` as a `forge.cmd` shim, which
53
- the plugin spawns through `cmd.exe` (a bare spawn cannot execute it); timeout
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 web add @agent_forge/forge-dsh
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 web add "github:MjxUpUp/Forge#main&path:/plugins/forge-dsh"
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 web add "link:."
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 <name> remove @agent_forge/forge-dsh` (the desktop app's profile
74
- is `desktop`), or keep it installed but inert with `enabled:false`.
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; FORGE_BIN only fills in when this key is absent
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, injects a visible notice
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 # dev-only: @deepseek-ai/cordis for the wiring tests
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): every collected context is queued via inject.
162
- * Best-effort by design — emit listeners are detached, so context may miss
163
- * the first request of a short-lived session (the official bridges document
164
- * the same TODO(session-start-gating) limitation).
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
- * @param {GroupOutcome} outcome
167
- * @param {object} agent
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 sessionStartOutcome(outcome, agent) {
170
- injectContexts(agent, outcome.contexts);
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 (default "forge", env FORGE_BIN wins over default)
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, turnStoppingOutcome, sessionStartOutcome } from "./decisions.js";
33
- import { buildEventPayload, buildToolPayload, matchedCommands, pluginMessage, promptText, toCCToolName } from "./tools.js";
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: config.forgeBin ?? process.env.FORGE_BIN ?? "forge",
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 agent.inject — the same
74
- // channel forge's own advisories use. Once a probe succeeds the answer is
75
- // cached for the process lifetime; forgeBin is static config per plugin
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
- "Install it with `npm install -g @agent_forge/forge`, then start a new session; " +
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
- const probe = await probeInFlight;
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
- if (agent != null && !noticedAgents.has(agent)) {
103
- noticedAgents.add(agent);
104
- try {
105
- await agent.inject?.(pluginMessage(missingForgeNotice(probe.error)));
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
- if (commands.length === 0) return next();
135
- const eventPayload = buildEventPayload("UserPromptSubmit", payload?.agent, {
136
- prompt: promptText(payload?.messages),
137
- });
138
- const outcome = await runHookGroup(commands, eventPayload, opts);
139
- noteErrors("pre-step", outcome);
140
- return preStepDecision(outcome, next, payload?.agent);
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 via agent.inject.
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
- (async () => {
149
- await ensureForge(agent);
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
- sessionStartOutcome(start, agent);
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
- sessionStartOutcome(compact, agent);
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.80.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
  },