@agent_forge/forge-dsh 0.1.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/LICENSE +201 -0
- package/README.md +101 -0
- package/cordis.patch.yml +3 -0
- package/lib/decisions.js +185 -0
- package/lib/index.js +167 -0
- package/lib/runner.js +121 -0
- package/lib/spec.json +80 -0
- package/lib/tools.js +187 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 MjxUpUp
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @agent_forge/forge-dsh
|
|
2
|
+
|
|
3
|
+
Forge quality gates for **DeepSeek Harness (dsh)** — task gates, read-before-edit,
|
|
4
|
+
bash hazard interception, and quality scoring, enforced inside DSH sessions through
|
|
5
|
+
the harness's typed interception points.
|
|
6
|
+
|
|
7
|
+
[Forge](https://github.com/MjxUpUp/Forge) is an AI-code quality gate engine. This
|
|
8
|
+
plugin is a thin Cordis wrapper: it forwards DSH tool/session events to the `forge`
|
|
9
|
+
CLI and translates forge's verdicts back into DSH decisions. All gate logic lives in
|
|
10
|
+
the forge binary — the plugin itself has **zero runtime dependencies**.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
13
|
+
|
|
14
|
+
| DSH interception point | forge hook event | A forge block becomes |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `tools/pre-execute` | `PreToolUse` | `{kind:'deny', reason}` |
|
|
17
|
+
| `tools/post-execute` | `PostToolUse` | `{kind:'block', feedback}` (error result) |
|
|
18
|
+
| `agent/pre-step` | `UserPromptSubmit` | `{kind:'reject'}` |
|
|
19
|
+
| `agent/session-start` | `SessionStart`¹ | context via `agent.inject()` |
|
|
20
|
+
| `agent/turn-stopping` | `Stop` | `agent.steer(reason)` → another step |
|
|
21
|
+
|
|
22
|
+
¹ `source:'compact'` additionally fires forge's `PostCompact` group — DSH rc.7
|
|
23
|
+
exposes no dedicated compaction point.
|
|
24
|
+
|
|
25
|
+
DSH tool names map onto the Claude Code names forge dispatches on
|
|
26
|
+
(`write/edit/str_replace_editor→Write/Edit`, `bash/pwsh→Bash`, `read→Read`,
|
|
27
|
+
`skill→Skill`); unmapped tools pass ungated. The wired hook roster mirrors
|
|
28
|
+
forge's canonical spec (`lib/spec.json`, drift-guarded by a Go test in the Forge
|
|
29
|
+
repo) — freeze-guard, task-guard, assertion-check, read-before-edit, bash-guard,
|
|
30
|
+
hazard-guard, auto-compile, workflow-test-guard, file-sentinel, tool-track,
|
|
31
|
+
task-verify, review-stop, skill-trigger, and the session-start group.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- The `forge` CLI on `PATH` (`npm install -g @agent_forge/forge`), with the project
|
|
36
|
+
initialized (`forge init`) for task gates to have state to enforce.
|
|
37
|
+
- DeepSeek Harness `0.1.0-rc.x` (verified against `0.1.0-rc.7`), Node.js ≥ 18.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
# from npm
|
|
43
|
+
dsh plugin --profile web add @agent_forge/forge-dsh
|
|
44
|
+
|
|
45
|
+
# or straight from the Forge repo (subdirectory install)
|
|
46
|
+
dsh plugin --profile web add "github:MjxUpUp/Forge#main&path:/plugins/forge-dsh"
|
|
47
|
+
|
|
48
|
+
# local development
|
|
49
|
+
dsh plugin --profile web add "link:$(pwd)"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Restart `dsh web` after install. Check status inside a session with `/forge-status`.
|
|
53
|
+
|
|
54
|
+
## Config
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
# profile patch
|
|
58
|
+
- insert:
|
|
59
|
+
- id: forge-quality
|
|
60
|
+
name: "@agent_forge/forge-dsh"
|
|
61
|
+
config:
|
|
62
|
+
forgeBin: forge # binary name/path (env FORGE_BIN overrides the default)
|
|
63
|
+
timeoutMs: 30000 # per-hook ceiling; timeout kills and FAILS OPEN
|
|
64
|
+
enabled: true # false unwires every listener
|
|
65
|
+
debug: false # log fail-open hook errors to console.error
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Fail-open contract
|
|
69
|
+
|
|
70
|
+
Blocks are read from forge's stdout JSON (`decision:"block"`), never from exit
|
|
71
|
+
codes — a forge internal error (exit 1) is indistinguishable from a deny otherwise.
|
|
72
|
+
Every infrastructure failure (forge missing, spawn error, unparseable output,
|
|
73
|
+
timeout) **fails open**: a forge outage never locks the agent out of its own tools.
|
|
74
|
+
Such failures are silent by design; surface them with `/forge-status` or `debug:true`.
|
|
75
|
+
|
|
76
|
+
Two latency trade-offs to know: hooks run **serially** per event (up to five per
|
|
77
|
+
group), so a slow hook chain delays the tool call — and a gate that legitimately
|
|
78
|
+
exceeds `timeoutMs` (e.g. `auto-compile` cold-building a large project past 30s)
|
|
79
|
+
silently loses that run's feedback to fail-open (visible in `/forge-status`).
|
|
80
|
+
Raise `timeoutMs` on big projects.
|
|
81
|
+
|
|
82
|
+
## Known watch items (dsh preview)
|
|
83
|
+
|
|
84
|
+
- **No stop-hook loop guard** in rc.7 — a permanently-failing Stop gate steers at
|
|
85
|
+
every stop boundary. forge's Stop gates are self-limiting once their gate passes,
|
|
86
|
+
but a stuck gate loops the turn.
|
|
87
|
+
- **SessionStart context is best-effort** (emit mode, detached): it may miss the
|
|
88
|
+
first request of a very short-lived session — same limitation the official
|
|
89
|
+
bridges document (`TODO(session-start-gating)`).
|
|
90
|
+
- DSH is a developer preview; pin this plugin's version alongside your dsh version.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm install # dev-only: @deepseek-ai/cordis for the wiring tests
|
|
96
|
+
npm test # 33 tests: mapping, runner, decision folding, real-cordis wiring
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`test/doubles/fake-forge.mjs` stands in for the forge binary; the wiring suite
|
|
100
|
+
boots a real cordis runtime and asserts dispatch order, short-circuit semantics,
|
|
101
|
+
payload shape, and every decision mapping.
|
package/cordis.patch.yml
ADDED
package/lib/decisions.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — decision folding (pure logic, no I/O).
|
|
3
|
+
*
|
|
4
|
+
* One forge hook group (a spec event's matched commands) runs serially and
|
|
5
|
+
* collapses to `{ blocked?, contexts, errors }`:
|
|
6
|
+
* - the first block wins and short-circuits the rest of the group (the
|
|
7
|
+
* spec's own ordering contract — freeze-guard runs first precisely so its
|
|
8
|
+
* deny pre-empts task-guard's);
|
|
9
|
+
* - allow-path additionalContext strings accumulate in order;
|
|
10
|
+
* - infrastructure failures (fail-open verdicts) are collected for
|
|
11
|
+
* observability but never influence the outcome.
|
|
12
|
+
*
|
|
13
|
+
* The collapse then maps onto DSH's typed decisions per interception point
|
|
14
|
+
* (see index.js): deny for tools/pre-execute, block+feedback for
|
|
15
|
+
* tools/post-execute, reject for agent/pre-step, steering for
|
|
16
|
+
* agent/turn-stopping.
|
|
17
|
+
*
|
|
18
|
+
* @module decisions
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { runForgeHook } from "./runner.js";
|
|
22
|
+
import { pluginMessage } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} GroupOutcome
|
|
26
|
+
* @property {{reason: string, command: string}|undefined} blocked
|
|
27
|
+
* @property {string[]} contexts - allow-path additionalContext, in order.
|
|
28
|
+
* @property {string[]} errors - fail-open infrastructure notes.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run one matched hook group serially against one payload.
|
|
33
|
+
*
|
|
34
|
+
* @param {string[]} commands - `forge hook <name>` commands, spec order.
|
|
35
|
+
* @param {object} payload - Claude-Code-shape stdin payload (shared by the
|
|
36
|
+
* whole group, as in Claude Code).
|
|
37
|
+
* @param {object} [opts] - forwarded to runForgeHook (forgeBin/timeoutMs/cwd).
|
|
38
|
+
* @returns {Promise<GroupOutcome>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runHookGroup(commands, payload, opts = {}) {
|
|
41
|
+
const run = opts.runner ?? runForgeHook; // DI seam for tests
|
|
42
|
+
const outcome = { blocked: undefined, contexts: [], errors: [] };
|
|
43
|
+
for (const command of commands) {
|
|
44
|
+
const verdict = await run(command, payload, opts);
|
|
45
|
+
if (verdict.error !== undefined) outcome.errors.push(`${command}: ${verdict.error}`);
|
|
46
|
+
if (verdict.block) {
|
|
47
|
+
outcome.blocked = { reason: verdict.reason ?? "denied", command };
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (verdict.context !== undefined) outcome.contexts.push(verdict.context);
|
|
51
|
+
}
|
|
52
|
+
return outcome;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* tools/pre-execute: forge block → deny; otherwise delegate. Allow-path
|
|
57
|
+
* context has no channel on PreToolDecision (allow carries nothing), so it is
|
|
58
|
+
* queued into the agent's next pre-step via agent.inject — the seam dsh-agent
|
|
59
|
+
* documents for exactly this ("Queue model-facing context for the next
|
|
60
|
+
* pre-step without waking the driver").
|
|
61
|
+
*
|
|
62
|
+
* @param {GroupOutcome} outcome
|
|
63
|
+
* @param {object|undefined} agent
|
|
64
|
+
* @param {() => Promise<object>} next
|
|
65
|
+
* @returns {Promise<object>} PreToolDecision
|
|
66
|
+
*/
|
|
67
|
+
export async function preExecuteDecision(outcome, agent, next) {
|
|
68
|
+
if (outcome.blocked !== undefined) {
|
|
69
|
+
return { kind: "deny", reason: outcome.blocked.reason };
|
|
70
|
+
}
|
|
71
|
+
injectContexts(agent, outcome.contexts);
|
|
72
|
+
return next();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* tools/post-execute: forge block → block with the reason as corrective
|
|
77
|
+
* feedback (the post-execute "block turns corrective feedback into an error
|
|
78
|
+
* result" channel). The block variant of PostToolDecision carries NO
|
|
79
|
+
* additionalContexts (that field belongs to accept), so contexts gathered
|
|
80
|
+
* before the block are queued via agent.inject instead of being attached to
|
|
81
|
+
* a decision shape the runtime would drop (or worse, reject on a stricter
|
|
82
|
+
* schema check). Allow-path context folds into the downstream decision AFTER
|
|
83
|
+
* delegating (delegate-then-prepend: returning an enter-style decision
|
|
84
|
+
* without next() would short-circuit every later listener).
|
|
85
|
+
*
|
|
86
|
+
* @param {GroupOutcome} outcome
|
|
87
|
+
* @param {() => Promise<object>} next
|
|
88
|
+
* @param {object|undefined} [agent] - for the block-path context fallback.
|
|
89
|
+
* @returns {Promise<object>} PostToolDecision
|
|
90
|
+
*/
|
|
91
|
+
export async function postExecuteDecision(outcome, next, agent) {
|
|
92
|
+
if (outcome.blocked !== undefined) {
|
|
93
|
+
injectContexts(agent, outcome.contexts);
|
|
94
|
+
return {
|
|
95
|
+
kind: "block",
|
|
96
|
+
feedback: [{ type: "text", text: outcome.blocked.reason }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const decision = await next();
|
|
100
|
+
if (outcome.contexts.length === 0) return decision;
|
|
101
|
+
return {
|
|
102
|
+
...decision,
|
|
103
|
+
additionalContexts: [
|
|
104
|
+
...outcome.contexts.map(pluginMessage),
|
|
105
|
+
...(decision?.additionalContexts ?? []),
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* agent/pre-step (UserPromptSubmit): forge block → reject the step. PreStepDecision's
|
|
112
|
+
* reject variant carries no reason, so the gate's corrective feedback (and any
|
|
113
|
+
* same-group contexts) is queued via agent.inject first — on Claude Code the
|
|
114
|
+
* UserPromptSubmit block reason reaches the user, and it must not silently
|
|
115
|
+
* vanish here. Allow-path context prepends plugin messages into the
|
|
116
|
+
* downstream enter decision (reject passes through untouched).
|
|
117
|
+
*
|
|
118
|
+
* @param {GroupOutcome} outcome
|
|
119
|
+
* @param {() => Promise<object>} next
|
|
120
|
+
* @param {object|undefined} [agent] - for the reject-path reason/context fallback.
|
|
121
|
+
* @returns {Promise<object>} PreStepDecision
|
|
122
|
+
*/
|
|
123
|
+
export async function preStepDecision(outcome, next, agent) {
|
|
124
|
+
if (outcome.blocked !== undefined) {
|
|
125
|
+
injectContexts(agent, [outcome.blocked.reason, ...outcome.contexts]);
|
|
126
|
+
return { kind: "reject" };
|
|
127
|
+
}
|
|
128
|
+
const decision = await next();
|
|
129
|
+
if (outcome.contexts.length === 0 || decision?.kind !== "enter") return decision;
|
|
130
|
+
return {
|
|
131
|
+
kind: "enter",
|
|
132
|
+
messages: [...outcome.contexts.map(pluginMessage), ...decision.messages],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* agent/turn-stopping (Stop): forge block steers the agent into another step
|
|
138
|
+
* (dsh-agent: "a listener that objects steers and the machine re-reads its
|
|
139
|
+
* inbox: fresh steering runs another step, none closes the turn"); allow-path
|
|
140
|
+
* context is queued via inject.
|
|
141
|
+
*
|
|
142
|
+
* NOTE: DSH rc.7 has no stop-hook loop guard (the official bridges always
|
|
143
|
+
* report stop_hook_active:false for the same reason). forge's Stop hooks are
|
|
144
|
+
* gate-driven and self-limiting (a passing gate stops blocking), but a
|
|
145
|
+
* permanently-failing gate will steer every stop boundary — surfaced in the
|
|
146
|
+
* README as a known watch item.
|
|
147
|
+
*
|
|
148
|
+
* @param {GroupOutcome} outcome
|
|
149
|
+
* @param {object} agent
|
|
150
|
+
* @returns {Promise<void>}
|
|
151
|
+
*/
|
|
152
|
+
export async function turnStoppingOutcome(outcome, agent) {
|
|
153
|
+
if (outcome.blocked !== undefined) {
|
|
154
|
+
agent?.steer?.(pluginMessage(outcome.blocked.reason));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
injectContexts(agent, outcome.contexts);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* agent/session-start (emit): every collected context is queued via inject.
|
|
162
|
+
* Best-effort by design — emit listeners are detached, so context may miss
|
|
163
|
+
* the first request of a short-lived session (the official bridges document
|
|
164
|
+
* the same TODO(session-start-gating) limitation).
|
|
165
|
+
*
|
|
166
|
+
* @param {GroupOutcome} outcome
|
|
167
|
+
* @param {object} agent
|
|
168
|
+
*/
|
|
169
|
+
export function sessionStartOutcome(outcome, agent) {
|
|
170
|
+
injectContexts(agent, outcome.contexts);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Queue each context string as one plugin-sourced message. */
|
|
174
|
+
function injectContexts(agent, contexts) {
|
|
175
|
+
if (contexts.length === 0) return;
|
|
176
|
+
const inject = agent?.inject;
|
|
177
|
+
if (typeof inject !== "function") return;
|
|
178
|
+
for (const text of contexts) {
|
|
179
|
+
try {
|
|
180
|
+
inject.call(agent, pluginMessage(text));
|
|
181
|
+
} catch {
|
|
182
|
+
// a throwing inject must never interrupt the session (bridge isolation rule)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — Forge quality gates for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Wires forge's Claude-Code hook roster (mirrored in lib/spec.json from
|
|
5
|
+
* internal/hooks/settings.go ForgeHookSpec — drift-guarded by a Go test in
|
|
6
|
+
* the Forge repo) onto DSH's typed interception points:
|
|
7
|
+
*
|
|
8
|
+
* tools/pre-execute → PreToolUse (block → {kind:'deny'})
|
|
9
|
+
* tools/post-execute → PostToolUse (block → {kind:'block', feedback})
|
|
10
|
+
* agent/pre-step → UserPromptSubmit (block → {kind:'reject'})
|
|
11
|
+
* agent/session-start → SessionStart (emit; source 'compact' also fires
|
|
12
|
+
* the PostCompact group — DSH rc.7 exposes no
|
|
13
|
+
* separate compaction point)
|
|
14
|
+
* agent/turn-stopping → Stop (block → agent.steer(reason))
|
|
15
|
+
*
|
|
16
|
+
* Each hook runs as `forge hook <name>` with a Claude-Code-shape stdin
|
|
17
|
+
* payload and speaks forge's claude stdout dialect back (block read from the
|
|
18
|
+
* JSON decision field; every infrastructure failure fails open — a forge
|
|
19
|
+
* outage never locks the agent out of its tools). Verified against
|
|
20
|
+
* @deepseek-ai/dsh 0.1.0-rc.7 type surface.
|
|
21
|
+
*
|
|
22
|
+
* Config (profile patch):
|
|
23
|
+
* forgeBin - forge binary name/path (default "forge", env FORGE_BIN wins over default)
|
|
24
|
+
* timeoutMs - per-hook ceiling, kill + fail open (default 30000)
|
|
25
|
+
* enabled - false disables every listener (default true)
|
|
26
|
+
* debug - log hook failures to console.error (default false)
|
|
27
|
+
*
|
|
28
|
+
* @module @agent_forge/forge-dsh
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { runHookGroup, preExecuteDecision, postExecuteDecision, preStepDecision, turnStoppingOutcome, sessionStartOutcome } from "./decisions.js";
|
|
32
|
+
import { buildEventPayload, buildToolPayload, matchedCommands, promptText, toCCToolName } from "./tools.js";
|
|
33
|
+
|
|
34
|
+
const spec = JSON.parse(readFileSync(new URL("./spec.json", import.meta.url), "utf8"));
|
|
35
|
+
|
|
36
|
+
/** Stable Cordis plugin name. */
|
|
37
|
+
const name = "forge-quality";
|
|
38
|
+
/** Hard dependency: the tool registry (pre/post-execute waterfalls). */
|
|
39
|
+
const inject = ["tools"];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} ctx - Cordis plugin context.
|
|
43
|
+
* @param {object} [config] - { forgeBin?, timeoutMs?, enabled?, debug? }.
|
|
44
|
+
*/
|
|
45
|
+
function apply(ctx, config = {}) {
|
|
46
|
+
if (config.enabled === false) return;
|
|
47
|
+
const opts = {
|
|
48
|
+
forgeBin: config.forgeBin ?? process.env.FORGE_BIN ?? "forge",
|
|
49
|
+
timeoutMs: config.timeoutMs ?? 30000,
|
|
50
|
+
};
|
|
51
|
+
const debug = config.debug === true;
|
|
52
|
+
// Recent-run ring buffer for /forge-status — fail-open verdicts are silent
|
|
53
|
+
// by design, so this (plus debug logging) is the only way to see them.
|
|
54
|
+
const recentRuns = [];
|
|
55
|
+
const noteErrors = (label, outcome) => {
|
|
56
|
+
recentRuns.push({
|
|
57
|
+
label,
|
|
58
|
+
blocked: outcome.blocked?.command ?? null,
|
|
59
|
+
contexts: outcome.contexts.length,
|
|
60
|
+
errors: outcome.errors,
|
|
61
|
+
at: Date.now(),
|
|
62
|
+
});
|
|
63
|
+
if (recentRuns.length > 50) recentRuns.shift();
|
|
64
|
+
if (debug && outcome.errors.length > 0) {
|
|
65
|
+
console.error(`[forge-dsh] ${label} fail-open: ${outcome.errors.join("; ")}`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// PreToolUse — deny short-circuits the tool call.
|
|
70
|
+
ctx.effect(() => ctx.on("tools/pre-execute", async (exec, next) => {
|
|
71
|
+
const commands = matchedCommands(spec.PreToolUse, toCCToolName(exec?.name ?? ""));
|
|
72
|
+
if (commands.length === 0) return next();
|
|
73
|
+
const outcome = await runHookGroup(commands, buildToolPayload(exec, "PreToolUse"), opts);
|
|
74
|
+
noteErrors("pre-execute", outcome);
|
|
75
|
+
return preExecuteDecision(outcome, exec?.agent, next);
|
|
76
|
+
}), "forge: pre-execute");
|
|
77
|
+
|
|
78
|
+
// PostToolUse — block turns the gate's reason into an error result.
|
|
79
|
+
ctx.effect(() => ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
80
|
+
const commands = matchedCommands(spec.PostToolUse, toCCToolName(exec?.name ?? ""));
|
|
81
|
+
if (commands.length === 0) return next();
|
|
82
|
+
const outcome = await runHookGroup(commands, buildToolPayload(exec, "PostToolUse"), opts);
|
|
83
|
+
noteErrors("post-execute", outcome);
|
|
84
|
+
return postExecuteDecision(outcome, next, exec?.agent);
|
|
85
|
+
}), "forge: post-execute");
|
|
86
|
+
|
|
87
|
+
// UserPromptSubmit — the prompt rides the step's claimed messages.
|
|
88
|
+
ctx.effect(() => ctx.on("agent/pre-step", async (payload, next) => {
|
|
89
|
+
const commands = matchedCommands(spec.UserPromptSubmit, "");
|
|
90
|
+
if (commands.length === 0) return next();
|
|
91
|
+
const eventPayload = buildEventPayload("UserPromptSubmit", payload?.agent, {
|
|
92
|
+
prompt: promptText(payload?.messages),
|
|
93
|
+
});
|
|
94
|
+
const outcome = await runHookGroup(commands, eventPayload, opts);
|
|
95
|
+
noteErrors("pre-step", outcome);
|
|
96
|
+
return preStepDecision(outcome, next, payload?.agent);
|
|
97
|
+
}), "forge: pre-step");
|
|
98
|
+
|
|
99
|
+
// SessionStart — emit mode: detached, contexts queued via agent.inject.
|
|
100
|
+
// source 'compact' doubles as PostCompact (DSH exposes no dedicated point).
|
|
101
|
+
ctx.effect(() => ctx.on("agent/session-start", (payload) => {
|
|
102
|
+
const agent = payload?.agent;
|
|
103
|
+
const source = payload?.source;
|
|
104
|
+
(async () => {
|
|
105
|
+
const start = await runHookGroup(
|
|
106
|
+
matchedCommands(spec.SessionStart, ""),
|
|
107
|
+
buildEventPayload("SessionStart", agent, { source }),
|
|
108
|
+
opts,
|
|
109
|
+
);
|
|
110
|
+
noteErrors("session-start", start);
|
|
111
|
+
sessionStartOutcome(start, agent);
|
|
112
|
+
if (source === "compact") {
|
|
113
|
+
const compact = await runHookGroup(
|
|
114
|
+
matchedCommands(spec.PostCompact, ""),
|
|
115
|
+
buildEventPayload("PostCompact", agent, {}),
|
|
116
|
+
opts,
|
|
117
|
+
);
|
|
118
|
+
noteErrors("post-compact", compact);
|
|
119
|
+
sessionStartOutcome(compact, agent);
|
|
120
|
+
}
|
|
121
|
+
})().catch((error) => {
|
|
122
|
+
if (debug) console.error(`[forge-dsh] session-start failed open: ${error}`);
|
|
123
|
+
});
|
|
124
|
+
}), "forge: session-start");
|
|
125
|
+
|
|
126
|
+
// Stop — a blocking gate steers the agent into another step.
|
|
127
|
+
ctx.effect(() => ctx.on("agent/turn-stopping", async (payload) => {
|
|
128
|
+
const agent = payload?.agent;
|
|
129
|
+
const outcome = await runHookGroup(
|
|
130
|
+
matchedCommands(spec.Stop, ""),
|
|
131
|
+
buildEventPayload("Stop", agent, { stop_hook_active: false }),
|
|
132
|
+
opts,
|
|
133
|
+
);
|
|
134
|
+
noteErrors("turn-stopping", outcome);
|
|
135
|
+
return turnStoppingOutcome(outcome, agent);
|
|
136
|
+
}), "forge: turn-stopping");
|
|
137
|
+
|
|
138
|
+
// /forge-status — wired groups + recent runs (the only place fail-open
|
|
139
|
+
// infrastructure errors surface without debug:true).
|
|
140
|
+
const commands = ctx.get("commands");
|
|
141
|
+
if (commands !== undefined) {
|
|
142
|
+
ctx.effect(() => commands.register({
|
|
143
|
+
name: "forge-status",
|
|
144
|
+
description: "Forge quality gates: wired hook groups and recent runs",
|
|
145
|
+
handler: async () => {
|
|
146
|
+
const groups = Object.entries(spec)
|
|
147
|
+
.map(([event, gs]) => ` ${event}: ${gs.flatMap((g) => g.hooks.map((h) => h.command.replace("forge hook ", ""))).join(", ")}`)
|
|
148
|
+
.join("\n");
|
|
149
|
+
const runs = recentRuns.length === 0
|
|
150
|
+
? " (no hook runs yet)"
|
|
151
|
+
: recentRuns.slice(-10).map((r) => {
|
|
152
|
+
const parts = [` ${new Date(r.at).toISOString()} ${r.label}`];
|
|
153
|
+
if (r.blocked) parts.push(`blocked-by=${r.blocked}`);
|
|
154
|
+
if (r.contexts > 0) parts.push(`contexts=${r.contexts}`);
|
|
155
|
+
if (r.errors.length > 0) parts.push(`FAIL-OPEN: ${r.errors.join("; ")}`);
|
|
156
|
+
return parts.join(" ");
|
|
157
|
+
}).join("\n");
|
|
158
|
+
return {
|
|
159
|
+
kind: "success",
|
|
160
|
+
text: `# Forge quality gates\n\nforgeBin: ${opts.forgeBin} timeoutMs: ${opts.timeoutMs}\n\n## Wired groups\n${groups}\n\n## Recent runs (last 10)\n${runs}`,
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
}), "forge: status command");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { apply, inject, name };
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — spawn layer.
|
|
3
|
+
*
|
|
4
|
+
* Runs one `forge hook <name>` command with a Claude-Code-shape JSON payload on
|
|
5
|
+
* stdin and parses forge's verdict from its stdout JSON. The contract is the
|
|
6
|
+
* one forge's claude emitter writes (internal/cli/hook.go emitClaudeOutput):
|
|
7
|
+
*
|
|
8
|
+
* pass, no detail : exit 0, empty stdout
|
|
9
|
+
* pass + detail : exit 0, {"hookSpecificOutput":{"hookEventName","additionalContext"}}
|
|
10
|
+
* block : exit 2, {"decision":"block","reason":...,"hookSpecificOutput":{...}}
|
|
11
|
+
* plus the reason on stderr
|
|
12
|
+
*
|
|
13
|
+
* Block is read from the JSON `decision` field — never from the exit code —
|
|
14
|
+
* because cobra reports forge's internal errors as exit 1, indistinguishable
|
|
15
|
+
* from a deny. Every infrastructure failure (forge missing, spawn error, JSON
|
|
16
|
+
* parse failure, timeout) FAILS OPEN so a forge outage never locks the agent
|
|
17
|
+
* out of its own tools. Same contract as the opencode/pi translators
|
|
18
|
+
* (internal/agentbridge/forge_spawn.ts).
|
|
19
|
+
*
|
|
20
|
+
* @module runner
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} HookVerdict
|
|
26
|
+
* @property {boolean} block - forge denied the action.
|
|
27
|
+
* @property {string} [reason] - block reason (shown to the model).
|
|
28
|
+
* @property {string} [context] - allow-path additionalContext to inject.
|
|
29
|
+
* @property {string} [error] - infrastructure failure note (fail-open path).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run one forge hook command.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} command - spec command, e.g. "forge hook task-guard".
|
|
36
|
+
* @param {object} payload - Claude-Code-shape hook stdin payload.
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @param {string} [opts.forgeBin="forge"] - forge binary (path or PATH name).
|
|
39
|
+
* @param {number} [opts.timeoutMs=30000] - kill + fail open past this.
|
|
40
|
+
* @param {string} [opts.cwd] - working directory for the hook.
|
|
41
|
+
* @returns {Promise<HookVerdict>}
|
|
42
|
+
*/
|
|
43
|
+
export function runForgeHook(command, payload, opts = {}) {
|
|
44
|
+
const forgeBin = opts.forgeBin ?? "forge";
|
|
45
|
+
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
46
|
+
const parts = command.split(" "); // ["forge","hook","task-guard"]
|
|
47
|
+
const argv = [forgeBin, ...parts.slice(1)];
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let child;
|
|
50
|
+
try {
|
|
51
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
52
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
53
|
+
cwd: opts.cwd,
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
resolve({ block: false, error: note(error) });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let out = "";
|
|
60
|
+
let settled = false;
|
|
61
|
+
// 30s default ceiling: a hung forge process (index.lock, blocked stdin,
|
|
62
|
+
// AV scan) would otherwise never resolve this Promise and freeze the
|
|
63
|
+
// agent's tool call forever. Timeout → kill + fail open.
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
try {
|
|
68
|
+
child.kill();
|
|
69
|
+
} catch {
|
|
70
|
+
// already exited — nothing to kill
|
|
71
|
+
}
|
|
72
|
+
resolve({ block: false, error: `timeout after ${timeoutMs}ms` });
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
const settle = (verdict) => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve(verdict);
|
|
79
|
+
};
|
|
80
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
81
|
+
child.on("error", (error) => settle({ block: false, error: note(error) }));
|
|
82
|
+
child.on("close", () => {
|
|
83
|
+
const text = out.trim();
|
|
84
|
+
if (text === "") return settle({ block: false });
|
|
85
|
+
try {
|
|
86
|
+
const j = JSON.parse(text);
|
|
87
|
+
const context = j?.hookSpecificOutput?.additionalContext;
|
|
88
|
+
if (j?.decision === "block") {
|
|
89
|
+
return settle({
|
|
90
|
+
block: true,
|
|
91
|
+
reason: j?.reason ?? context ?? "denied",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return settle({
|
|
95
|
+
block: false,
|
|
96
|
+
context: typeof context === "string" && context !== "" ? context : undefined,
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
settle({ block: false, error: "unparseable forge stdout" });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// stdin error guard: a forge that exits WITHOUT reading stdin (unknown
|
|
103
|
+
// hook name after a version drift, a broken FORGE_BIN shim, an early
|
|
104
|
+
// panic) makes this write raise EPIPE on the stdin stream — an unhandled
|
|
105
|
+
// 'error' event here would crash the DSH HOST PROCESS, the exact opposite
|
|
106
|
+
// of the fail-open contract. The verdict itself still comes from
|
|
107
|
+
// close/stdout above; swallowing the stream error is sufficient.
|
|
108
|
+
//
|
|
109
|
+
// stdin 错误护栏:forge 不读 stdin 就提前退出(版本漂移后的未知 hook 名、
|
|
110
|
+
// 坏掉的 FORGE_BIN shim、早期 panic)会让这次写入在 stdin 流上抛
|
|
111
|
+
// EPIPE——此处未处理的 'error' 事件会**崩溃 DSH 宿主进程**,与
|
|
112
|
+
// fail-open 契约正好相反。判定仍由上面的 close/stdout 路径产出,吞掉
|
|
113
|
+
// 流错误即可。
|
|
114
|
+
child.stdin.on("error", () => {});
|
|
115
|
+
child.stdin.end(JSON.stringify(payload) + "\n");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function note(error) {
|
|
120
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
121
|
+
}
|
package/lib/spec.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PostToolUse": [
|
|
3
|
+
{
|
|
4
|
+
"matcher": "Write|Edit",
|
|
5
|
+
"hooks": [
|
|
6
|
+
{ "type": "command", "command": "forge hook auto-compile" },
|
|
7
|
+
{ "type": "command", "command": "forge hook workflow-test-guard" },
|
|
8
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"matcher": "Bash",
|
|
13
|
+
"hooks": [
|
|
14
|
+
{ "type": "command", "command": "forge hook file-sentinel" },
|
|
15
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"matcher": "Read|Skill|Agent",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{ "type": "command", "command": "forge hook tool-track" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PreToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": "Write|Edit",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{ "type": "command", "command": "forge hook freeze-guard" },
|
|
30
|
+
{ "type": "command", "command": "forge hook task-guard" },
|
|
31
|
+
{ "type": "command", "command": "forge hook assertion-check" },
|
|
32
|
+
{ "type": "command", "command": "forge hook read-before-edit" },
|
|
33
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"matcher": "Bash",
|
|
38
|
+
"hooks": [
|
|
39
|
+
{ "type": "command", "command": "forge hook bash-guard" },
|
|
40
|
+
{ "type": "command", "command": "forge hook hazard-guard" },
|
|
41
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"Stop": [
|
|
46
|
+
{
|
|
47
|
+
"hooks": [
|
|
48
|
+
{ "type": "command", "command": "forge hook task-verify" },
|
|
49
|
+
{ "type": "command", "command": "forge hook review-stop" },
|
|
50
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"SessionStart": [
|
|
55
|
+
{
|
|
56
|
+
"hooks": [
|
|
57
|
+
{ "type": "command", "command": "forge hook skill-scan" },
|
|
58
|
+
{ "type": "command", "command": "forge hook mcp-scan" },
|
|
59
|
+
{ "type": "command", "command": "forge hook init-suggest" },
|
|
60
|
+
{ "type": "command", "command": "forge hook task-resume" },
|
|
61
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"PostCompact": [
|
|
66
|
+
{
|
|
67
|
+
"hooks": [
|
|
68
|
+
{ "type": "command", "command": "forge hook compact-resume" }
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"UserPromptSubmit": [
|
|
73
|
+
{
|
|
74
|
+
"hooks": [
|
|
75
|
+
{ "type": "command", "command": "forge hook resume-reinject" },
|
|
76
|
+
{ "type": "command", "command": "forge hook skill-trigger" }
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @agent_forge/forge-dsh — DSH → Claude-Code vocabulary mapping.
|
|
3
|
+
*
|
|
4
|
+
* forge's hook dispatch keys on Claude Code tool names (Write/Edit/Bash/Read/
|
|
5
|
+
* Skill/Agent) and Claude-shape payloads (HookInput in internal/cli/hook.go).
|
|
6
|
+
* DSH's built-in fs tools already use Claude-style argument field names
|
|
7
|
+
* (file_path/content/old_string/new_string — verified against
|
|
8
|
+
* @deepseek-ai/dsh-tool-fs 0.1.0-rc.7), so tool_input is a light alias pass
|
|
9
|
+
* rather than a deep translation.
|
|
10
|
+
*
|
|
11
|
+
* @module tools
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** DSH built-in tool name → Claude Code tool name forge dispatches on. */
|
|
15
|
+
export const CC_TOOL_NAME = {
|
|
16
|
+
write: "Write",
|
|
17
|
+
edit: "Edit",
|
|
18
|
+
str_replace_editor: "Edit", // Minimal preset's editor tool
|
|
19
|
+
bash: "Bash",
|
|
20
|
+
pwsh: "Bash",
|
|
21
|
+
read: "Read",
|
|
22
|
+
read_image: "Read",
|
|
23
|
+
skill: "Skill",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map a DSH tool name to its Claude Code equivalent. Unknown names pass
|
|
28
|
+
* through unchanged — no spec matcher will match them, so they sail through
|
|
29
|
+
* the waterfalls ungated (same as an unmatched tool in Claude Code).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} dshName
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function toCCToolName(dshName) {
|
|
35
|
+
return CC_TOOL_NAME[dshName] ?? dshName;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Field aliases from non-CC argument shapes to Claude's tool_input keys. */
|
|
39
|
+
const INPUT_ALIASES = {
|
|
40
|
+
filePath: "file_path",
|
|
41
|
+
path: "file_path",
|
|
42
|
+
newText: "content",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize one tool's argument object into Claude tool_input shape. DSH fs
|
|
47
|
+
* tools already use file_path/content/old_string/new_string, so this mostly
|
|
48
|
+
* copies; aliases cover camelCase variants seen in community tools. The
|
|
49
|
+
* original arguments object is never mutated.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} args
|
|
52
|
+
* @returns {Record<string, unknown>}
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeToolInput(args) {
|
|
55
|
+
const out = {};
|
|
56
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) return out;
|
|
57
|
+
for (const [key, value] of Object.entries(args)) {
|
|
58
|
+
out[INPUT_ALIASES[key] ?? key] = value;
|
|
59
|
+
}
|
|
60
|
+
// A bare alias must not shadow an already-correct CC field (args carrying
|
|
61
|
+
// both file_path and path: file_path wins regardless of iteration order).
|
|
62
|
+
for (const [alias, canonical] of Object.entries(INPUT_ALIASES)) {
|
|
63
|
+
if (args[alias] !== undefined && args[canonical] !== undefined) {
|
|
64
|
+
out[canonical] = args[canonical];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect the spec hook commands of one event whose matcher accepts ccName.
|
|
72
|
+
* A group with an empty/absent matcher always matches; otherwise the matcher
|
|
73
|
+
* is an alternation regex tested against the Claude tool name (identical to
|
|
74
|
+
* Claude Code's matcher semantics for forge's own spec — every matcher in
|
|
75
|
+
* spec.json is a plain alternation like "Write|Edit").
|
|
76
|
+
*
|
|
77
|
+
* @param {Array<{matcher?: string, hooks: Array<{command: string}>}>} groups
|
|
78
|
+
* @param {string} ccName
|
|
79
|
+
* @returns {string[]} matched commands, in spec order
|
|
80
|
+
*/
|
|
81
|
+
export function matchedCommands(groups, ccName) {
|
|
82
|
+
const commands = [];
|
|
83
|
+
for (const group of groups ?? []) {
|
|
84
|
+
if (group.matcher !== undefined && group.matcher !== "") {
|
|
85
|
+
if (!new RegExp(`^(?:${group.matcher})$`).test(ccName)) continue;
|
|
86
|
+
}
|
|
87
|
+
for (const hook of group.hooks ?? []) commands.push(hook.command);
|
|
88
|
+
}
|
|
89
|
+
return commands;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the session identity forge expects from a DSH agent handle.
|
|
94
|
+
* Falls back to process cwd and an empty session id (forge degrades to its
|
|
95
|
+
* legacy global state file for an empty session id — same degradation the
|
|
96
|
+
* official bridges accept when no transcript locator exists).
|
|
97
|
+
*
|
|
98
|
+
* @param {object|undefined} agent
|
|
99
|
+
* @returns {{ sessionId: string, cwd: string }}
|
|
100
|
+
*/
|
|
101
|
+
export function sessionBits(agent) {
|
|
102
|
+
const session = agent?.session;
|
|
103
|
+
return {
|
|
104
|
+
sessionId: session?.id ?? "",
|
|
105
|
+
cwd: session?.header?.cwd ?? process.cwd(),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the Claude-Code-shape stdin payload for one tool event.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} exec - DSH ToolExecution ({name, arguments, agent?}).
|
|
113
|
+
* @param {"PreToolUse"|"PostToolUse"} event
|
|
114
|
+
* @returns {object}
|
|
115
|
+
*/
|
|
116
|
+
export function buildToolPayload(exec, event) {
|
|
117
|
+
const { sessionId, cwd } = sessionBits(exec?.agent);
|
|
118
|
+
return {
|
|
119
|
+
session_id: sessionId,
|
|
120
|
+
transcript_path: "",
|
|
121
|
+
cwd,
|
|
122
|
+
hook_event_name: event,
|
|
123
|
+
tool_name: toCCToolName(exec?.name ?? ""),
|
|
124
|
+
tool_input: normalizeToolInput(exec?.arguments),
|
|
125
|
+
forge_agent: "dsh",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the stdin payload for a non-tool event (Stop / SessionStart /
|
|
131
|
+
* UserPromptSubmit / PostCompact).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} event
|
|
134
|
+
* @param {object|undefined} agent
|
|
135
|
+
* @param {object} [extra] - extra fields (prompt, source, stop_hook_active…).
|
|
136
|
+
* @returns {object}
|
|
137
|
+
*/
|
|
138
|
+
export function buildEventPayload(event, agent, extra = {}) {
|
|
139
|
+
const { sessionId, cwd } = sessionBits(agent);
|
|
140
|
+
return {
|
|
141
|
+
session_id: sessionId,
|
|
142
|
+
transcript_path: "",
|
|
143
|
+
cwd,
|
|
144
|
+
hook_event_name: event,
|
|
145
|
+
forge_agent: "dsh",
|
|
146
|
+
...extra,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Concatenate the visible text of user messages into one prompt string
|
|
152
|
+
* (forge's UserPromptSubmit consumers read a flat `prompt` field).
|
|
153
|
+
*
|
|
154
|
+
* @param {Array<{content?: Array<{type: string, text?: string}>}>} messages
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function promptText(messages) {
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const message of messages ?? []) {
|
|
160
|
+
for (const block of message?.content ?? []) {
|
|
161
|
+
if (block?.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return parts.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let messageSeq = 0;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build one plugin-sourced user message (the shape dsh-llm's
|
|
171
|
+
* createUserMessage freezes: role 'user', text content, plugin source).
|
|
172
|
+
* Constructed literally to keep this package dependency-free — importing
|
|
173
|
+
* @deepseek-ai/dsh-llm would only resolve when the install layout happens to
|
|
174
|
+
* nest it above us.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} text
|
|
177
|
+
* @returns {{id: string, role: "user", content: Array<{type: "text", text: string}>, source: {kind: "plugin", plugin: string}}}
|
|
178
|
+
*/
|
|
179
|
+
export function pluginMessage(text) {
|
|
180
|
+
messageSeq += 1;
|
|
181
|
+
return {
|
|
182
|
+
id: `forge-dsh-${Date.now()}-${messageSeq}`,
|
|
183
|
+
role: "user",
|
|
184
|
+
content: [{ type: "text", text }],
|
|
185
|
+
source: { kind: "plugin", plugin: "forge-quality" },
|
|
186
|
+
};
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent_forge/forge-dsh",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/MjxUpUp/Forge",
|
|
8
|
+
"directory": "plugins/forge-dsh"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/index.js",
|
|
14
|
+
"lib/runner.js",
|
|
15
|
+
"lib/decisions.js",
|
|
16
|
+
"lib/tools.js",
|
|
17
|
+
"lib/spec.json",
|
|
18
|
+
"cordis.patch.yml"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test lib/*.test.js"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh",
|
|
30
|
+
"dsh-plugin",
|
|
31
|
+
"deepseek-harness",
|
|
32
|
+
"quality-gates",
|
|
33
|
+
"hooks",
|
|
34
|
+
"ai-code-quality"
|
|
35
|
+
],
|
|
36
|
+
"license": "Apache-2.0",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
42
|
+
}
|
|
43
|
+
}
|