@agent_forge/forge-dsh 1.82.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
@@ -100,6 +100,9 @@ under `~/.dsh/profiles/<profile>/`):
100
100
  timeoutMs: 30000 # per-hook ceiling; timeout kills and FAILS OPEN
101
101
  startWaitMs: 1500 # first pre-step waits this long for session-start contexts
102
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
103
106
  enabled: true # false unwires every listener
104
107
  debug: false # log fail-open hook errors to console.error
105
108
  ```
@@ -122,24 +125,64 @@ while the binary is missing, every hook attempt fails open on its own, so gates
122
125
  inactive whether or not the probe ran. Installing forge needs no host restart: the
123
126
  next session probes again and gating resumes.
124
127
 
125
- Two latency trade-offs to know: hooks run **serially** per event (up to six per
126
- group), so a slow hook chain delays the tool call — and a gate that legitimately
127
- exceeds `timeoutMs` (e.g. `auto-compile` cold-building a large project past 30s)
128
- silently loses that run's feedback to fail-open (visible in `/forge-status`).
129
- Raise `timeoutMs` on big projects.
130
-
131
- `/forge-status` reports the probe line after the header: `probe: ok (forge version …)`,
132
- `probe: FAILED — gates inactive (…)`, or `not run yet` before the process's first
133
- 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.
134
177
 
135
178
  ## Known watch items (dsh preview)
136
179
 
137
180
  - **No stop-hook loop guard** in the preview builds (`0.1.0-rc.7` – `0.2.1-rc.2`) —
138
181
  a permanently-failing Stop gate steers at every stop boundary. forge's Stop gates
139
182
  are self-limiting once their gate passes, but a stuck gate loops the turn.
140
- - **SessionStart context is best-effort** (emit mode, detached): it may miss the
141
- first request of a very short-lived session — same limitation the official
142
- 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.
143
186
  - DSH is a developer preview; pin this plugin's version alongside your dsh version.
144
187
 
145
188
  ## Development
package/lib/bundled.js CHANGED
@@ -68,20 +68,3 @@ export function resolveBundledForge(opts = {}) {
68
68
  return undefined;
69
69
  }
70
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
@@ -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
 
package/lib/index.js CHANGED
@@ -25,6 +25,13 @@
25
25
  * the forge bundled with this plugin (dependency, version-locked) >
26
26
  * "forge" on PATH
27
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)
28
35
  * welcome - false drops the one-line first-turn onboarding (default true)
29
36
  * startWaitMs - how long the first pre-step waits for the session-start
30
37
  * contexts before moving on without them (default 1500)
@@ -34,14 +41,20 @@
34
41
  * @module @agent_forge/forge-dsh
35
42
  */
36
43
  import { readFileSync } from "node:fs";
37
- import { bundledForgeHint, resolveBundledForge } from "./bundled.js";
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";
38
48
  import { probeForge } from "./runner.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";
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";
41
51
 
42
52
  const spec = JSON.parse(readFileSync(new URL("./spec.json", import.meta.url), "utf8"));
43
53
  const pluginVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
44
54
 
55
+ /** Fail-open runs in one session before the degraded advisory fires. */
56
+ const DEGRADED_AFTER = 3;
57
+
45
58
  /** Stable Cordis plugin name. */
46
59
  const name = "forge-quality";
47
60
  /** Hard dependency: the tool registry (pre/post-execute waterfalls). */
@@ -66,19 +79,40 @@ function apply(ctx, config = {}) {
66
79
  const opts = {
67
80
  forgeBin: explicitBin ?? bundledBin ?? "forge",
68
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 },
69
91
  };
92
+ const batchMode = config.batch === true ? "forced" : config.batch === false ? "off" : "auto";
70
93
  const debug = config.debug === true;
71
94
  const welcome = config.welcome !== false;
95
+ let skillsOffered = false;
96
+ const msg = messagesFor(resolveLocale(config.locale));
72
97
  const startWaitMs = Number.isFinite(config.startWaitMs) && config.startWaitMs >= 0 ? config.startWaitMs : 1500;
73
98
  // Recent-run ring buffer for /forge-status — fail-open verdicts are silent
74
99
  // by design, so this (plus debug logging) is the only way to see them.
75
100
  const recentRuns = [];
76
- 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;
77
110
  recentRuns.push({
78
111
  label,
79
112
  blocked: outcome.blocked?.command ?? null,
80
113
  contexts: outcome.contexts.length,
81
114
  errors: outcome.errors,
115
+ ms: outcome.durationMs ?? 0,
82
116
  at: Date.now(),
83
117
  });
84
118
  if (recentRuns.length > 50) recentRuns.shift();
@@ -137,12 +171,6 @@ function apply(ctx, config = {}) {
137
171
  let forgeReady = false;
138
172
  let lastProbe;
139
173
  let probeInFlight;
140
- const missingForgeNotice = (error) =>
141
- `[forge-dsh] forge CLI not found (forgeBin: ${opts.forgeBin}). ` +
142
- "Forge gates are OFF for this session — every action runs unguarded (fail-open). " +
143
- "Reinstall the plugin (it bundles forge) or run `npm install -g @agent_forge/forge`, then start a new session; " +
144
- "if it lives elsewhere, set forgeBin in the profile patch." +
145
- (error === undefined ? "" : `\nProbe error: ${error}`);
146
174
  const ensureForge = async (agent) => {
147
175
  if (forgeReady) return true;
148
176
  if (probeInFlight === undefined) {
@@ -166,32 +194,33 @@ function apply(ctx, config = {}) {
166
194
  lastProbe = probe;
167
195
  if (probe.ok) {
168
196
  forgeReady = true;
197
+ if (batchMode === "auto") opts.batch = (probe.version?.match(/\d+\.\d+\.\d+/) ?? [])[0] === pluginVersion;
169
198
  return true;
170
199
  }
171
200
  if (debug) console.error(`[forge-dsh] forge probe failed: ${probe.error}`);
172
201
  const state = startStateOf(agent);
173
202
  if (state !== undefined && !state.noticed) {
174
203
  state.noticed = true;
175
- state.pending.push(missingForgeNotice(probe.error));
204
+ state.pending.push(msg.missingForge(opts.forgeBin, probe.error));
176
205
  }
177
206
  return false;
178
207
  };
179
208
 
180
209
  // PreToolUse — deny short-circuits the tool call.
181
210
  ctx.effect(() => ctx.on("tools/pre-execute", async (exec, next) => {
182
- const commands = matchedCommands(spec.PreToolUse, toCCToolName(exec?.name ?? ""));
183
- if (commands.length === 0) return next();
184
- const outcome = await runHookGroup(commands, buildToolPayload(exec, "PreToolUse"), opts);
185
- 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);
186
215
  return preExecuteDecision(outcome, exec?.agent, next);
187
216
  }), "forge: pre-execute");
188
217
 
189
218
  // PostToolUse — block turns the gate's reason into an error result.
190
219
  ctx.effect(() => ctx.on("tools/post-execute", async (exec, result, next) => {
191
- const commands = matchedCommands(spec.PostToolUse, toCCToolName(exec?.name ?? ""));
192
- if (commands.length === 0) return next();
193
- const outcome = await runHookGroup(commands, buildToolPayload(exec, "PostToolUse"), opts);
194
- 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);
195
224
  return postExecuteDecision(outcome, next, exec?.agent);
196
225
  }), "forge: post-execute");
197
226
 
@@ -199,16 +228,16 @@ function apply(ctx, config = {}) {
199
228
  ctx.effect(() => ctx.on("agent/pre-step", async (payload, next) => {
200
229
  const state = startStateOf(payload?.agent);
201
230
  const startWait = waitStart(state);
202
- const commands = matchedCommands(spec.UserPromptSubmit, "");
231
+ const groups = matchedGroups(spec.UserPromptSubmit, "");
203
232
  let decision;
204
- if (commands.length === 0) {
233
+ if (groups.length === 0) {
205
234
  decision = await next();
206
235
  } else {
207
236
  const eventPayload = buildEventPayload("UserPromptSubmit", payload?.agent, {
208
237
  prompt: promptText(payload?.messages),
209
238
  });
210
- const outcome = await runHookGroup(commands, eventPayload, opts);
211
- noteErrors("pre-step", outcome);
239
+ const outcome = await runMatchedGroups("UserPromptSubmit", groups, eventPayload, opts);
240
+ noteErrors("pre-step", outcome, payload?.agent);
212
241
  decision = await preStepDecision(outcome, next, payload?.agent);
213
242
  }
214
243
  await startWait;
@@ -242,14 +271,15 @@ function apply(ctx, config = {}) {
242
271
  if (source === "compact" && state !== undefined) state.hinted = false;
243
272
  if (forgeOk && binSource === "bundled" && state !== undefined && !state.hinted) {
244
273
  state.hinted = true;
245
- state.pending.push(bundledForgeHint(opts.forgeBin));
274
+ state.pending.push(msg.bundledHint(opts.forgeBin));
246
275
  }
247
- const start = await runHookGroup(
248
- matchedCommands(spec.SessionStart, ""),
276
+ const start = await runMatchedGroups(
277
+ "SessionStart",
278
+ matchedGroups(spec.SessionStart, ""),
249
279
  buildEventPayload("SessionStart", agent, { source }),
250
280
  opts,
251
281
  );
252
- noteErrors("session-start", start);
282
+ noteErrors("session-start", start, agent);
253
283
  queue(start);
254
284
  // Welcome — queue order: welcome, path hint, forge's own contexts. One
255
285
  // human-facing line per session: startup only (resume/compact are not a
@@ -262,15 +292,24 @@ function apply(ctx, config = {}) {
262
292
  if (forgeOk && welcome && isStartup && managed && !isSubagent(agent) && state !== undefined && !state.welcomed) {
263
293
  state.welcomed = true;
264
294
  const forgeVersion = (lastProbe?.version?.match(/\d+\.\d+\.\d+/) ?? [])[0];
265
- state.pending.unshift(welcomeText(pluginVersion, forgeVersion));
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
+ }
266
304
  }
267
305
  if (source === "compact") {
268
- const compact = await runHookGroup(
269
- matchedCommands(spec.PostCompact, ""),
306
+ const compact = await runMatchedGroups(
307
+ "PostCompact",
308
+ matchedGroups(spec.PostCompact, ""),
270
309
  buildEventPayload("PostCompact", agent, {}),
271
310
  opts,
272
311
  );
273
- noteErrors("post-compact", compact);
312
+ noteErrors("post-compact", compact, agent);
274
313
  queue(compact);
275
314
  }
276
315
  })().catch((error) => {
@@ -286,13 +325,23 @@ function apply(ctx, config = {}) {
286
325
  // Stop — a blocking gate steers the agent into another step.
287
326
  ctx.effect(() => ctx.on("agent/turn-stopping", async (payload) => {
288
327
  const agent = payload?.agent;
289
- const outcome = await runHookGroup(
290
- matchedCommands(spec.Stop, ""),
328
+ const outcome = await runMatchedGroups(
329
+ "Stop",
330
+ matchedGroups(spec.Stop, ""),
291
331
  buildEventPayload("Stop", agent, { stop_hook_active: false }),
292
332
  opts,
293
333
  );
294
- noteErrors("turn-stopping", outcome);
295
- 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;
296
345
  }), "forge: turn-stopping");
297
346
 
298
347
  // /forge-status — wired groups + verdict summary + recent runs (the only
@@ -327,9 +376,45 @@ function apply(ctx, config = {}) {
327
376
  const parts = [` ${new Date(r.at).toISOString()} ${r.label}`];
328
377
  if (r.blocked) parts.push(`blocked-by=${r.blocked}`);
329
378
  if (r.contexts > 0) parts.push(`contexts=${r.contexts}`);
379
+ parts.push(`${r.ms}ms`);
330
380
  if (r.errors.length > 0) parts.push(`FAIL-OPEN: ${r.errors.join("; ")}`);
331
381
  return parts.join(" ");
332
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
+ }
333
418
  const probeLine = lastProbe === undefined
334
419
  ? "not run yet (no session start in this process)"
335
420
  : lastProbe.ok
@@ -337,7 +422,7 @@ function apply(ctx, config = {}) {
337
422
  : `FAILED — gates inactive (${lastProbe.error})`;
338
423
  return {
339
424
  kind: "success",
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}`,
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}`,
341
426
  };
342
427
  },
343
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
@@ -221,23 +242,3 @@ export function isSubagent(agent) {
221
242
  const header = agent?.session?.header;
222
243
  return header?.origin === "subagent" || header?.parentSession !== undefined;
223
244
  }
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.82.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",
@@ -14,6 +14,9 @@
14
14
  "lib/index.js",
15
15
  "lib/runner.js",
16
16
  "lib/bundled.js",
17
+ "lib/messages.js",
18
+ "lib/skills.js",
19
+ "lib/latest.js",
17
20
  "lib/decisions.js",
18
21
  "lib/tools.js",
19
22
  "lib/spec.json",
@@ -37,7 +40,7 @@
37
40
  ],
38
41
  "license": "Apache-2.0",
39
42
  "dependencies": {
40
- "@agent_forge/forge": "1.82.0"
43
+ "@agent_forge/forge": "1.83.0"
41
44
  },
42
45
  "engines": {
43
46
  "node": ">=18"