@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 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,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; 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)
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, injects a visible notice
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 trade-offs to know: hooks run **serially** per event (up to six per
112
- group), so a slow hook chain delays the tool call — and a gate that legitimately
113
- exceeds `timeoutMs` (e.g. `auto-compile` cold-building a large project past 30s)
114
- silently loses that run's feedback to fail-open (visible in `/forge-status`).
115
- Raise `timeoutMs` on big projects.
116
-
117
- `/forge-status` reports the probe line after the header: `probe: ok (forge version …)`,
118
- `probe: FAILED — gates inactive (…)`, or `not run yet` before the process's first
119
- session start.
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 is best-effort** (emit mode, detached): it may miss the
127
- first request of a very short-lived session — same limitation the official
128
- bridges document (`TODO(session-start-gating)`).
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 # dev-only: @deepseek-ai/cordis for the wiring tests
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 outcome = { blocked: undefined, contexts: [], errors: [] };
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): 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).
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
- * @param {GroupOutcome} outcome
167
- * @param {object} agent
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 sessionStartOutcome(outcome, agent) {
170
- injectContexts(agent, outcome.contexts);
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 (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
+ * 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 { runHookGroup, preExecuteDecision, postExecuteDecision, preStepDecision, turnStoppingOutcome, sessionStartOutcome } from "./decisions.js";
33
- import { buildEventPayload, buildToolPayload, matchedCommands, pluginMessage, promptText, toCCToolName } from "./tools.js";
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: config.forgeBin ?? process.env.FORGE_BIN ?? "forge",
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 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.
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
- const probe = await probeInFlight;
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
- 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
- }
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 commands = matchedCommands(spec.PreToolUse, toCCToolName(exec?.name ?? ""));
116
- if (commands.length === 0) return next();
117
- const outcome = await runHookGroup(commands, buildToolPayload(exec, "PreToolUse"), opts);
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 commands = matchedCommands(spec.PostToolUse, toCCToolName(exec?.name ?? ""));
125
- if (commands.length === 0) return next();
126
- const outcome = await runHookGroup(commands, buildToolPayload(exec, "PostToolUse"), opts);
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 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);
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 via agent.inject.
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
- (async () => {
149
- await ensureForge(agent);
150
- const start = await runHookGroup(
151
- matchedCommands(spec.SessionStart, ""),
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
- sessionStartOutcome(start, agent);
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 runHookGroup(
159
- matchedCommands(spec.PostCompact, ""),
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
- sessionStartOutcome(compact, agent);
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 runHookGroup(
175
- matchedCommands(spec.Stop, ""),
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
- return turnStoppingOutcome(outcome, agent);
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
+ }
@@ -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 needsQuotes = /[&|()<>^,;=\s]/.test(forgeBin);
60
- const file = needsQuotes && !forgeBin.includes('"') ? `"${forgeBin}"` : forgeBin;
61
- return { file, args: argv, shell: true };
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 parts = command.split(" "); // ["forge","hook","task-guard"]
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.81.0",
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
  },