@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 +55 -12
- package/lib/bundled.js +0 -17
- package/lib/decisions.js +129 -2
- package/lib/index.js +121 -36
- package/lib/latest.js +90 -0
- package/lib/messages.js +110 -0
- package/lib/runner.js +38 -7
- package/lib/skills.js +38 -0
- package/lib/tools.js +21 -20
- package/package.json +5 -2
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
|
|
126
|
-
group
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
40
|
-
import { buildEventPayload, buildToolPayload,
|
|
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(
|
|
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
|
|
183
|
-
if (
|
|
184
|
-
const outcome = await
|
|
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
|
|
192
|
-
if (
|
|
193
|
-
const outcome = await
|
|
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
|
|
231
|
+
const groups = matchedGroups(spec.UserPromptSubmit, "");
|
|
203
232
|
let decision;
|
|
204
|
-
if (
|
|
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
|
|
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(
|
|
274
|
+
state.pending.push(msg.bundledHint(opts.forgeBin));
|
|
246
275
|
}
|
|
247
|
-
const start = await
|
|
248
|
-
|
|
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(
|
|
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
|
|
269
|
-
|
|
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
|
|
290
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/messages.js
ADDED
|
@@ -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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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.
|
|
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.
|
|
43
|
+
"@agent_forge/forge": "1.83.0"
|
|
41
44
|
},
|
|
42
45
|
"engines": {
|
|
43
46
|
"node": ">=18"
|