@agent_forge/forge-dsh 1.78.0 → 1.79.1

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
@@ -25,25 +25,30 @@ the forge binary — the plugin itself has **zero runtime dependencies**.
25
25
  | `agent/session-start` | `SessionStart`¹ | context via `agent.inject()` |
26
26
  | `agent/turn-stopping` | `Stop` | `agent.steer(reason)` → another step |
27
27
 
28
- ¹ `source:'compact'` additionally fires forge's `PostCompact` group — DSH rc.7
29
- exposes no dedicated compaction point.
28
+ ¹ `source:'compact'` additionally fires forge's `PostCompact` group — the preview
29
+ builds expose no dedicated compaction point.
30
30
 
31
31
  DSH tool names map onto the Claude Code names forge dispatches on
32
- (`write/edit/str_replace_editor→Write/Edit`, `bash/pwsh→Bash`, `read→Read`,
32
+ (`write/edit/str_replace_editor→Write/Edit`, `bash/pwsh→Bash`, `read/read_image→Read`,
33
33
  `skill→Skill`); unmapped tools pass ungated. The wired hook roster mirrors
34
34
  forge's canonical spec (`lib/spec.json`, drift-guarded by a Go test in the Forge
35
35
  repo) — freeze-guard, task-guard, assertion-check, read-before-edit, bash-guard,
36
36
  hazard-guard, auto-compile, workflow-test-guard, file-sentinel, tool-track,
37
37
  test-nudge, failure-track (PostToolUseFailure), subagent-track (SubagentStop),
38
- task-verify, review-stop, skill-trigger, and the session-start group.
38
+ task-verify, review-stop, skill-trigger, doc-lint, conventions-write, gate-cmd-form,
39
+ task-drift, resume-reinject, compact-resume, conventions-context, and the
40
+ session-start group.
39
41
  failure-track/subagent-track are mirrored in the spec but inert until DSH
40
- exposes the corresponding event points (rc.7 has neither).
42
+ exposes the corresponding event points (the preview builds have neither).
41
43
 
42
44
  ## Requirements
43
45
 
44
46
  - The `forge` CLI on `PATH` (`npm install -g @agent_forge/forge`), with the project
45
47
  initialized (`forge init`) for task gates to have state to enforce.
46
- - DeepSeek Harness `0.1.0-rc.x` (verified against `0.1.0-rc.7`), Node.js ≥ 18.
48
+ - DeepSeek Harness `0.1.0-rc.7` – `0.2.1-rc.2`, Node.js ≥ 18. The bridge contract is
49
+ pinned to `0.1.0-rc.7` ([`contract.json`](./contract.json)); `0.2.1-rc.2` is the
50
+ desktop build this plugin runs on (pre-tool interception verified live there; a
51
+ full five-event regression across the range is still pending).
47
52
  - Windows works out of the box: npm lays out `forge` as a `forge.cmd` shim, which
48
53
  the plugin spawns through `cmd.exe` (a bare spawn cannot execute it); timeout
49
54
  kills tear down the whole child tree via `taskkill /T`.
@@ -57,45 +62,67 @@ dsh plugin --profile web add @agent_forge/forge-dsh
57
62
  # or straight from the Forge repo (subdirectory install)
58
63
  dsh plugin --profile web add "github:MjxUpUp/Forge#main&path:/plugins/forge-dsh"
59
64
 
60
- # local development
61
- dsh plugin --profile web add "link:$(pwd)"
65
+ # local development (run from this directory; `link:.` works on Windows too)
66
+ dsh plugin --profile web add "link:."
62
67
  ```
63
68
 
64
- Restart `dsh web` after install. Check status inside a session with `/forge-status`.
69
+ The plugin hot-loads into a running dsh — adding or removing the bundle reloads the
70
+ profile. If a given build does not pick it up, restart the host once. Start a new
71
+ session to pick up the SessionStart context, and check the bridge's state with
72
+ `/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`.
65
75
 
66
76
  ## Config
67
77
 
78
+ Keys go on the bundle entry in the profile's `cordis.patch.yml` — the user-editable
79
+ patch layer. Do not edit the generated `cordis.yml`: the loader rewrites that root on
80
+ every boot, so anything written there is lost (the desktop app manages its own profile
81
+ under `~/.dsh/profiles/<profile>/`):
68
82
  ```yaml
69
- # profile patch
70
- - insert:
71
- - id: forge-quality
72
- name: "@agent_forge/forge-dsh"
73
- config:
74
- forgeBin: forge # binary name/path (env FORGE_BIN overrides the default)
75
- timeoutMs: 30000 # per-hook ceiling; timeout kills and FAILS OPEN
76
- enabled: true # false unwires every listener
77
- debug: false # log fail-open hook errors to console.error
83
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml
84
+ - id: forge-quality
85
+ name: "@agent_forge/forge-dsh"
86
+ config:
87
+ forgeBin: forge # binary name/path; FORGE_BIN only fills in when this key is absent
88
+ timeoutMs: 30000 # per-hook ceiling; timeout kills and FAILS OPEN
89
+ enabled: true # false unwires every listener
90
+ debug: false # log fail-open hook errors to console.error
78
91
  ```
79
92
 
80
93
  ## Fail-open contract
81
94
 
82
- Blocks are read from forge's stdout JSON (`decision:"block"`), never from exit
83
- codes — a forge internal error (exit 1) is indistinguishable from a deny otherwise.
84
- Every infrastructure failure (forge missing, spawn error, unparseable output,
85
- timeout) **fails open**: a forge outage never locks the agent out of its own tools.
86
- Such failures are silent by design; surface them with `/forge-status` or `debug:true`.
87
-
88
- Two latency trade-offs to know: hooks run **serially** per event (up to five per
95
+ Blocks are read from forge's stdout JSON (`decision:"block"`), never inferred from
96
+ exit codes: the verdict reason and its `additionalContext` exist only in that JSON,
97
+ and forge reuses exit 2 for hard errors as well. Every infrastructure failure (forge
98
+ missing, spawn error, unparseable output, timeout) **fails open**: a forge outage
99
+ never locks the agent out of its own tools. Such hook-run failures are silent by
100
+ design; surface them with `/forge-status` or `debug:true`.
101
+
102
+ A missing `forge` binary is the one exception to that silence: each session start
103
+ 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
106
+ like a healthy plugin gating nothing. The notice is informational, not a switch —
107
+ while the binary is missing, every hook attempt fails open on its own, so gates are
108
+ inactive whether or not the probe ran. Installing forge needs no host restart: the
109
+ next session probes again and gating resumes.
110
+
111
+ Two latency trade-offs to know: hooks run **serially** per event (up to six per
89
112
  group), so a slow hook chain delays the tool call — and a gate that legitimately
90
113
  exceeds `timeoutMs` (e.g. `auto-compile` cold-building a large project past 30s)
91
114
  silently loses that run's feedback to fail-open (visible in `/forge-status`).
92
115
  Raise `timeoutMs` on big projects.
93
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.
120
+
94
121
  ## Known watch items (dsh preview)
95
122
 
96
- - **No stop-hook loop guard** in rc.7 — a permanently-failing Stop gate steers at
97
- every stop boundary. forge's Stop gates are self-limiting once their gate passes,
98
- but a stuck gate loops the turn.
123
+ - **No stop-hook loop guard** in the preview builds (`0.1.0-rc.7` – `0.2.1-rc.2`) —
124
+ a permanently-failing Stop gate steers at every stop boundary. forge's Stop gates
125
+ are self-limiting once their gate passes, but a stuck gate loops the turn.
99
126
  - **SessionStart context is best-effort** (emit mode, detached): it may miss the
100
127
  first request of a very short-lived session — same limitation the official
101
128
  bridges document (`TODO(session-start-gating)`).
package/lib/index.js CHANGED
@@ -28,8 +28,9 @@
28
28
  * @module @agent_forge/forge-dsh
29
29
  */
30
30
  import { readFileSync } from "node:fs";
31
+ import { probeForge } from "./runner.js";
31
32
  import { runHookGroup, preExecuteDecision, postExecuteDecision, preStepDecision, turnStoppingOutcome, sessionStartOutcome } from "./decisions.js";
32
- import { buildEventPayload, buildToolPayload, matchedCommands, promptText, toCCToolName } from "./tools.js";
33
+ import { buildEventPayload, buildToolPayload, matchedCommands, pluginMessage, promptText, toCCToolName } from "./tools.js";
33
34
 
34
35
  const spec = JSON.parse(readFileSync(new URL("./spec.json", import.meta.url), "utf8"));
35
36
 
@@ -66,6 +67,49 @@ function apply(ctx, config = {}) {
66
67
  }
67
68
  };
68
69
 
70
+ // A missing forge binary used to be completely silent: every hook failed
71
+ // open, so the plugin looked installed but gated nothing. Probe once per
72
+ // 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.
77
+ let forgeReady = false;
78
+ let lastProbe;
79
+ 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
+ const ensureForge = async (agent) => {
88
+ if (forgeReady) return true;
89
+ if (probeInFlight === undefined) {
90
+ probeInFlight = probeForge({ forgeBin: opts.forgeBin, timeoutMs: Math.min(opts.timeoutMs, 5000) })
91
+ .finally(() => {
92
+ probeInFlight = undefined;
93
+ });
94
+ }
95
+ const probe = await probeInFlight;
96
+ lastProbe = probe;
97
+ if (probe.ok) {
98
+ forgeReady = true;
99
+ return true;
100
+ }
101
+ 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
+ }
109
+ }
110
+ return false;
111
+ };
112
+
69
113
  // PreToolUse — deny short-circuits the tool call.
70
114
  ctx.effect(() => ctx.on("tools/pre-execute", async (exec, next) => {
71
115
  const commands = matchedCommands(spec.PreToolUse, toCCToolName(exec?.name ?? ""));
@@ -102,6 +146,7 @@ function apply(ctx, config = {}) {
102
146
  const agent = payload?.agent;
103
147
  const source = payload?.source;
104
148
  (async () => {
149
+ await ensureForge(agent);
105
150
  const start = await runHookGroup(
106
151
  matchedCommands(spec.SessionStart, ""),
107
152
  buildEventPayload("SessionStart", agent, { source }),
@@ -170,9 +215,14 @@ function apply(ctx, config = {}) {
170
215
  if (r.errors.length > 0) parts.push(`FAIL-OPEN: ${r.errors.join("; ")}`);
171
216
  return parts.join(" ");
172
217
  }).join("\n");
218
+ const probeLine = lastProbe === undefined
219
+ ? "not run yet (no session start in this process)"
220
+ : lastProbe.ok
221
+ ? `ok${lastProbe.version === undefined ? "" : ` (${lastProbe.version.slice(0, 80)})`}`
222
+ : `FAILED — gates inactive (${lastProbe.error})`;
173
223
  return {
174
224
  kind: "success",
175
- text: `# Forge quality gates\n\nforgeBin: ${opts.forgeBin} timeoutMs: ${opts.timeoutMs}\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}`,
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}`,
176
226
  };
177
227
  },
178
228
  }), "forge: status command");
package/lib/runner.js CHANGED
@@ -108,40 +108,7 @@ export function runForgeHook(command, payload, opts = {}) {
108
108
  const timer = setTimeout(() => {
109
109
  if (settled) return;
110
110
  settled = true;
111
- // On the win32 shell route the direct child is cmd.exe and the forge
112
- // grandchild holds the task index lock AND the inherited stdio pipes:
113
- // killing only the shell ORPHANS the grandchild (a dead parent's tree
114
- // is unfindable for /T) — it survives with the pipes open, pinning this
115
- // process's event loop forever. taskkill /T /F is therefore the primary
116
- // kill: it tears down shell + descendants atomically. kill() (wrapper
117
- // only) is the FALLBACK for when taskkill itself fails — spawn error
118
- // (not on PATH) or nonzero exit (access denied); killing just the
119
- // wrapper then still beats killing nothing. Non-shell routes kill the
120
- // forge process directly.
121
- if (plan.shell && child.pid) {
122
- const fallback = () => {
123
- try {
124
- child.kill();
125
- } catch {
126
- // already exited — nothing to kill
127
- }
128
- };
129
- try {
130
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
131
- killer.on("error", fallback);
132
- killer.on("close", (c) => {
133
- if (c !== 0) fallback();
134
- });
135
- } catch {
136
- fallback();
137
- }
138
- } else {
139
- try {
140
- child.kill();
141
- } catch {
142
- // already exited — nothing to kill
143
- }
144
- }
111
+ killTree(child, plan.shell);
145
112
  resolve({ block: false, error: `timeout after ${timeoutMs}ms` });
146
113
  }, timeoutMs);
147
114
  const settle = (verdict) => {
@@ -199,6 +166,114 @@ export function runForgeHook(command, payload, opts = {}) {
199
166
  });
200
167
  }
201
168
 
169
+ /**
170
+ * Kill a spawned forge and, on the win32 shell route, its whole process tree.
171
+ *
172
+ * On the win32 shell route the direct child is cmd.exe and the forge
173
+ * grandchild holds the task index lock AND the inherited stdio pipes: killing
174
+ * only the shell ORPHANS the grandchild (a dead parent's tree is unfindable
175
+ * for /T) — it survives with the pipes open, pinning this process's event
176
+ * loop forever. taskkill /T /F is therefore the primary kill: it tears down
177
+ * shell + descendants atomically. kill() (wrapper only) is the FALLBACK for
178
+ * when taskkill itself fails — spawn error (not on PATH) or nonzero exit
179
+ * (access denied); killing just the wrapper then still beats killing nothing.
180
+ * Non-shell routes kill the forge process directly.
181
+ *
182
+ * @param {import("node:child_process").ChildProcess} child
183
+ * @param {boolean} shell - planSpawn's shell flag for this child.
184
+ */
185
+ function killTree(child, shell) {
186
+ if (shell && child.pid) {
187
+ const fallback = () => {
188
+ try {
189
+ child.kill();
190
+ } catch {
191
+ // already exited — nothing to kill
192
+ }
193
+ };
194
+ try {
195
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
196
+ killer.on("error", fallback);
197
+ killer.on("close", (code) => {
198
+ if (code !== 0) fallback();
199
+ });
200
+ } catch {
201
+ fallback();
202
+ }
203
+ } else {
204
+ try {
205
+ child.kill();
206
+ } catch {
207
+ // already exited — nothing to kill
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Probe whether the configured forge binary is runnable: one `forge --version`
214
+ * spawn, stdout captured, bounded by `timeoutMs` (default 5000 — a probe must
215
+ * never hold up session start for a hook-sized 30s). Fail-open like the hook
216
+ * path: resolves `{ok:false, error}` instead of throwing, so a missing or
217
+ * broken binary can be reported to the user rather than silently voiding every
218
+ * gate.
219
+ *
220
+ * The `--version` subcommand does not read stdin (verified against forge
221
+ * 1.79.0), so stdin is ignored; a hung probe is killed with the same
222
+ * tree-safe kill as a hook.
223
+ *
224
+ * @param {object} [opts]
225
+ * @param {string} [opts.forgeBin="forge"]
226
+ * @param {number} [opts.timeoutMs=5000]
227
+ * @returns {Promise<{ok: boolean, version?: string, error?: string}>}
228
+ */
229
+ export function probeForge(opts = {}) {
230
+ const forgeBin = opts.forgeBin ?? "forge";
231
+ const timeoutMs = opts.timeoutMs ?? 5000;
232
+ const plan = planSpawn(forgeBin, ["--version"]);
233
+ return new Promise((resolve) => {
234
+ let child;
235
+ try {
236
+ const args = plan.shell ? [[plan.file, ...plan.args].join(" ")] : [plan.file, plan.args];
237
+ child = spawn(...args, {
238
+ stdio: ["ignore", "pipe", "pipe"],
239
+ ...(plan.shell ? { shell: true } : {}),
240
+ });
241
+ } catch (error) {
242
+ resolve({ ok: false, error: note(error) });
243
+ return;
244
+ }
245
+ let out = "";
246
+ let err = "";
247
+ let settled = false;
248
+ let timer;
249
+ const finish = (verdict) => {
250
+ if (settled) return;
251
+ settled = true;
252
+ clearTimeout(timer);
253
+ resolve(verdict);
254
+ };
255
+ timer = setTimeout(() => {
256
+ killTree(child, plan.shell);
257
+ finish({ ok: false, error: `timeout after ${timeoutMs}ms` });
258
+ }, timeoutMs);
259
+ child.stdout.on("data", (d) => (out += d.toString()));
260
+ child.stderr.on("data", (d) => (err += d.toString()));
261
+ child.on("error", (error) => finish({ ok: false, error: note(error) }));
262
+ child.on("close", (code) => {
263
+ if (code === 0) {
264
+ const version = out.trim();
265
+ finish(version === "" ? { ok: true } : { ok: true, version });
266
+ return;
267
+ }
268
+ // A missing binary on the win32 cmd.exe route exits nonzero with its
269
+ // message on stderr (cmd.exe itself spawned fine, so no 'error' event);
270
+ // keep that tail — it is the only diagnostic the user sees.
271
+ const tail = err.trim().slice(0, 200);
272
+ finish({ ok: false, error: `forge --version exited with code ${code}${tail === "" ? "" : `: ${tail}`}` });
273
+ });
274
+ });
275
+ }
276
+
202
277
  function note(error) {
203
278
  return (error instanceof Error ? error.message : String(error)).slice(0, 300);
204
279
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@agent_forge/forge-dsh",
3
- "version": "1.78.0",
3
+ "version": "1.79.1",
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",
7
7
  "url": "https://github.com/MjxUpUp/Forge",
8
8
  "directory": "plugins/forge-dsh"
9
9
  },
10
+ "homepage": "https://github.com/MjxUpUp/Forge/tree/main/plugins/forge-dsh",
10
11
  "type": "module",
11
12
  "main": "lib/index.js",
12
13
  "files": [