An ordinary eslint wrapper: lints after every Write/Edit, a /lint command, and a band showing current findings.

One real Claude Code mod, three configurations, four executions — an interop probe of DeepSeek Harness's experimental Claude Code Mods compatibility layer, with the full compatibility matrix and the run evidence to back it.
DeepSeek's v0.2.1-alpha.1 release shipped a bridge that runs Claude Code mods inside dsh, framed honestly as verifying that the Claude Code Mods API is "broadly a subset of what DeepSeek Harness plugins can do, rather than ... complete, practical compatibility." Every mod DeepSeek had actually run through that bridge lived in their own repo, and the four real mods in Anthropic's repo were assessed by reading, not running. This project is the missing external test: the same ordinary mod under Claude Code (reference) and under the dsh bridge, plus the same job rewritten as a native dsh plugin.
The subset claim holds for mods that observe, register, and enrich — tool watching, commands, tools, fs, storage, env, prompt context injection, command runs, the status band all behaved equivalently (both hosts even named a registered tool identically: mcp__lint-guard__lint_report).
It ends at three seams, all in the "mod shapes execution" category:
tool.call argument rewrites are dropped. The probe hook passed next({ ...e, command: "echo lint-guard-REWRITTEN-BY-MOD" }). Claude Code ran the rewritten command (its model noticed on its own). dsh ran the original, exactly as logged — the bridge validates that arguments match the logged call and discards the hook otherwise (RewriteRefusedError).next() result rewriting silently no-ops. Claude Code's tool result is {ref, result, text}; appending to text worked. dsh returns {result, isError} — no text field, so the identical mutation changes nothing, throws nothing.settings.read, fs.ancestors, process.spawn, session.repo, config.list, telemetry.log, ui.copy, prompt.read, model.complete) plus $.ui.notice. Eight rejections match the documented wording exactly; $.model.complete is a doc-vs-runtime discrepancy — the README says it "waits on a logged side-request event," this runtime rejected it outright. And tool.check, which fired 3× under Claude Code, never fires under the bridge.Full row-by-row evidence: COMPAT_MATRIX.md.
lint-guardAn ordinary eslint wrapper, deliberately boring — that's what makes it a fair test:
tool.call hook), reports via ui.log/toast/status/lint command and a lint_report toolprompt.submitui.render bandecho to next() — the outcome is visible in the tool output on both hostsThe mod journals its own host-API calls (name, ok, duration, result snippet) to .lint-guard-trace.jsonl via the mod API's own $.fs.write, so both hosts leave the same artifact. The journal covers the probed surface: $.ui.resolve is called directly in drawBand, and $.plugin.name/root share one entry. The Claude run's trace holds 35 distinct call labels; the dsh run's holds 33.
| # | Configuration | Notes |
|---|---|---|
| 1 | Claude Code 2.1.289, --plugin-dir, real model | 58-row trace; $0.14 in tokens; mod pre-validated with claude plugin validate |
| 2 | dsh 0.2.1-alpha.1 headless + bridge, scripted model | first attempt: write blocked by dsh's read-before-write policy (FS_NOT_OBSERVED) — a harness guard below the mods layer |
| 3 | same, read-first scenario | the clean run the matrix is built from |
| 4 | dsh headless, native plugin (no bridge) | same lint job via tools/post-execute + ctx.commands; needed inject: ['commands'] to boot |
Runs 2–4 use a scripted Anthropic-Messages server on localhost (model-server.mjs) behind the runtime's own dsh-llm-deepseek-api-key adapter via a baseURL override — deterministic and free, so every host difference is the harness, not the model.
COMPAT_MATRIX.md ← the deliverable: row-by-row matrix + run ledger + repro recipe
probe/
mod/lint-guard/ ← the mod; same hooks module for Claude Code and the dsh bridge
.claude-plugin/ hooks/ ← Claude Code packaging (plugin.json, hooks.json)
index.mjs ← dsh side: defineMod wrapper
eslint.config.mjs ← the rules the mod enforces
native/lint-guard-native.ts ← the same job as a plain dsh plugin (no bridge)
model-server.mjs ← scripted Messages-protocol model server (port 8931)
lint-guard.patch.yml ← dsh overlay: bridge + mod + scripted model route
native.patch.yml ← dsh overlay: native plugin + scripted model route
src/payment.js ← the file the agent rewrites in every run
workspace/ ← working directory for the Claude Code reference run
runs/ ← all captured evidence (traces, raw session logs, wire log)
Prerequisites: Node 20+, npm, pnpm on PATH (dsh plugin add shells out to it), and Claude Code 2.1.289+ authenticated for the reference run.
# 0. install (dsh CLI + bridge wrapper + eslint)
cd probe && npm install && cd workspace && npm install && cd ..
# 1. one-time: install the bridge into dsh's headless profile
./node_modules/.bin/dsh plugin --profile headless add \
@deepseek-ai/dsh-experimental-claude-code-mods@0.2.1-alpha.1
# 2. start the scripted model (restart before each run — beats are stateful)
node model-server.mjs &
# 3. dsh bridge run
rm -f .lint-guard-trace.jsonl runs/model-server-wire.jsonl
DEEPSEEK_API_KEY=probe-key ./node_modules/.bin/dsh --profile headless \
--patch ./lint-guard.patch.yml \
"Rewrite src/payment.js so that total returns (a + b) * (1 + rate). Then run exactly: echo lint-guard-probe. Then stop."
# 4. Claude Code reference run (from probe/workspace/)
cd workspace && rm -f .lint-guard-trace.jsonl && claude -p \
"Small task: use the Edit tool to change src/payment.js so that total returns (a + b) * (1 + rate). Then run exactly this Bash command: echo lint-guard-probe — nothing else. Then stop." \
--plugin-dir ../mod/lint-guard --allowedTools "Edit Write Bash" \
--output-format stream-json --verbose --max-turns 6
# 5. native-port run (no bridge)
cd .. && DEEPSEEK_API_KEY=probe-key ./node_modules/.bin/dsh --profile headless \
--patch ./native.patch.yml \
"Rewrite src/payment.js so that total returns (a + b) * (1 + rate). Then stop."
Artifacts to compare afterwards: .lint-guard-trace.jsonl in whichever directory the run used (dsh's session cwd is the launch directory; Claude Code's is where you invoked it), the raw dsh session under ~/.dsh/sessions/<workspace-slug>/<session-id>/session.v4.jsonl.zstd, and runs/model-server-wire.jsonl for the wire-level view.
@deepseek-ai/dsh-experimental-claude-code-mods 0.2.1-alpha.1 and Claude Code 2.1.289 (the bridge's docs target 2.1.287). Both move fast; expect drift.probe/runs/.~/.dsh/profiles/headless. Remove it with dsh plugin --profile headless remove if you want the profile pristine.docs/subsystems/claude-code-mods.md in the deepseek-harness repohooks/lint-guard.mjs 314 lines1// lint-guard — an ordinary eslint wrapper mod, and an interop probe.
2//
3// Under Claude Code it loads as a plugin (`claude --plugin-dir <this dir>`);
4// under the DeepSeek Harness the same module mounts through
5// @deepseek-ai/dsh-experimental-claude-code-mods (wrapped by ../index.mjs).
6//
7// Every host-API call is recorded (name, ok, ms, result preview or error)
8// and flushed to .lint-guard-trace.jsonl in the working directory through
9// $.fs.write, so both hosts leave a comparable artifact. The trace is
10// mirrored into $.store under "lint-guard:trace" after each flush.
11//
12// The module must stay runtime-portable: it imports nothing, because the
13// Claude Code hooks environment has no Node ("no DOM, no Node").
14
15const PREFIX = "[lint-guard]";
16const ESLINT_CONFIG = "eslint.config.mjs";
17
18// Module-level state. Claude Code: survives the mod's hot reload. The dsh
19// bridge: survives a remount (register re-runs on the same evaluated module).
20const findings = { files: 0, problems: 0, errors: 0, last: null };
21const seen = { toolCheck: 0, turnStep: 0, agentSpawn: 0, uiRender: 0, promptSubmit: 0 };
22const trace = [];
23
24function preview(v) {
25 try {
26 const s = JSON.stringify(v);
27 if (s === undefined) return String(v);
28 return s.length > 240 ? s.slice(0, 237) + "..." : s;
29 } catch {
30 return String(v);
31 }
32}
33
34function rec(row) {
35 const full = { ts: new Date().toISOString(), ...row };
36 trace.push(full);
37 return full;
38}
39
40// Every $ call in the mod goes through here: the row lands in the trace
41// whether the call resolves, rejects, or sits outside the served table.
42async function call($, name, fn) {
43 const row = rec({ call: name });
44 const t0 = Date.now();
45 try {
46 const v = await fn();
47 row.ok = true;
48 row.ms = Date.now() - t0;
49 if (v !== undefined) row.result = preview(v);
50 } catch (e) {
51 row.ok = false;
52 row.ms = Date.now() - t0;
53 row.error = String((e && e.message) || e).slice(0, 300);
54 }
55 return row;
56}
57
58async function flush($) {
59 await call($, "fs.write(.lint-guard-trace.jsonl)", () =>
60 $.fs.write(".lint-guard-trace.jsonl", trace.map((r) => JSON.stringify(r)).join("\n") + "\n")
61 );
62 await call($, "store.set(lint-guard:trace)", () =>
63 $.store.set("lint-guard:trace", { rows: trace.length, tail: trace.slice(-6).map(preview) })
64 );
65}
66
67// $.plugin.name / $.plugin.root may be properties or promise-returning
68// members depending on the host; accept either.
69async function pluginFacts($) {
70 const name = await $.plugin.name;
71 const root = await $.plugin.root;
72 return { name, root };
73}
74
75// Runs eslint with the mod's own flat config. The full stdout never enters
76// the trace (previews are truncated) — only the parsed summary does.
77async function lintPaths($, paths) {
78 const cwd = await $.session.cwd();
79 const facts = await pluginFacts($);
80 const bin = `${cwd}/node_modules/.bin/eslint`;
81 const config = `${facts.root}/${ESLINT_CONFIG}`;
82 const argv = [bin, "--no-warn-ignored", "-c", config, "--format", "json", ...paths];
83 const row = rec({ call: `process.run(eslint ${paths.join(" ")})` });
84 const t0 = Date.now();
85 let summary = { files: 0, problems: 0, errors: 0, line: "eslint produced no output" };
86 try {
87 const res = await $.process.run(argv);
88 row.ms = Date.now() - t0;
89 row.result = preview({ exitCode: res.exitCode, stdoutBytes: (res.stdout ?? "").length, stderr: String(res.stderr ?? "").slice(0, 120) });
90 let arr = [];
91 try {
92 arr = JSON.parse(res.stdout ?? "[]");
93 } catch {
94 row.parse = "stdout not eslint-json";
95 }
96 summary.files = arr.length;
97 summary.problems = arr.reduce((n, f) => n + (f.errorCount + f.warningCount), 0);
98 summary.errors = arr.reduce((n, f) => n + f.errorCount, 0);
99 const first = arr
100 .flatMap((f) => f.messages)
101 .slice(0, 2)
102 .map((m) => `${m.ruleId}: ${m.message}`)
103 .join("; ");
104 summary.line = first || (summary.files ? "clean" : `exit ${res.exitCode}: ${String(res.stderr ?? "").slice(0, 100)}`);
105 row.ok = true;
106 } catch (e) {
107 row.ok = false;
108 row.ms = Date.now() - t0;
109 row.error = String((e && e.message) || e).slice(0, 300);
110 summary.line = `eslint failed to run: ${row.error}`;
111 }
112 row.summary = summary;
113 return { row, summary };
114}
115
116function noteFindings(summary) {
117 findings.files = summary.files;
118 findings.problems = summary.problems;
119 findings.errors = summary.errors;
120 findings.last = summary.line;
121}
122
123async function drawBand($, e) {
124 const { Box, Text } = $.ui.resolve(e);
125 const color = findings.errors > 0 ? "yellow" : "green";
126 return Box({
127 flexDirection: "row",
128 paddingX: 1,
129 children: [
130 Text({ color, bold: true, children: `${PREFIX} ${findings.problems} finding(s)` }),
131 Text({ dimColor: true, children: findings.last ? ` last: ${String(findings.last).slice(0, 60)}` : "" }),
132 ],
133 });
134}
135
136export function register(on, options) {
137 const opts = options || {};
138 const runGaps = opts.gaps !== false;
139
140 // ---- silent observers: events the bridge documents as never raised ----
141 on("tool.check", async ($, e, next) => {
142 seen.toolCheck += 1;
143 rec({ event: "tool.check[observed]", n: seen.toolCheck, preview: preview(e).slice(0, 200) });
144 return next(e);
145 });
146 // (turn.step is also a silent-event probe candidate, but it streams —
147 // Claude Code requires an async-generator hook for it — and a generator
148 // hook risks the bridge mount, so it is left to the matrix's by-reading row.)
149 on("agent.spawn", async ($, e, next) => {
150 seen.agentSpawn += 1;
151 rec({ event: "agent.spawn[observed]", n: seen.agentSpawn });
152 return next(e);
153 });
154
155 // ---- prompt enrichment ----
156 on("prompt.submit", async ($, e, next) => {
157 seen.promptSubmit += 1;
158 rec({ event: "prompt.submit", text: String(e.text ?? "").slice(0, 160) });
159 const context = [...(e.context ?? []), `${PREFIX} watching src/**/*.js; files written or edited are linted automatically`];
160 return next({ ...e, context });
161 });
162
163 // ---- session lifecycle ----
164 on("session.start", async ($, e, next) => {
165 rec({ event: "session.start", opts: preview(opts) });
166
167 await call($, "plugin.name/root", async () => preview(await pluginFacts($)));
168 await call($, "env.get(LINT_GUARD_PATTERN)", () => $.env.get("LINT_GUARD_PATTERN"));
169 await call($, "command.register(lint)", () =>
170 $.command.register({ name: "lint", description: "Run eslint over the workspace and report findings" })
171 );
172 await call($, "tool.register(lint_report)", () =>
173 $.tool.register({
174 name: "lint_report",
175 description: "Returns the eslint findings lint-guard recorded most recently",
176 inputSchema: { type: "object", properties: {} },
177 })
178 );
179 await call($, "command.list", () => $.command.list());
180 await call($, "tool.list", async () => (await $.tool.list()).length);
181 await call($, "session.id", () => $.session.id());
182 await call($, "session.cwd", () => $.session.cwd());
183 await call($, "session.model", () => $.session.model());
184 await call($, "session.version", () => $.session.version());
185 await call($, "session.usage", () => $.session.usage());
186 await call($, "session.messages", async () => (await $.session.messages()).length);
187
188 await flush($); // checkpoint before probes that may hang or reject
189
190 await call($, "fs.exists(src)", () => $.fs.exists("src"));
191
192 if (runGaps) {
193 // ---- documented-gap battery: members the bridge says it does not serve.
194 // Each is wrapped; whatever the host does lands in the trace. $.model.complete
195 // goes last: under the bridge it waits on a side-request event, and a hang
196 // costs this hook its 10s budget, which is itself the finding.
197 await call($, "GAP settings.read", () => $.settings.read());
198 await call($, "GAP fs.ancestors", () => $.fs.ancestors({ names: [ESLINT_CONFIG] }));
199 await call($, "GAP process.spawn", () => $.process.spawn(["/usr/bin/true"]));
200 await call($, "GAP session.repo", () => $.session.repo());
201 await call($, "GAP config.list", () => $.config.list());
202 await call($, "GAP telemetry.log", () => $.telemetry.log("lint-guard probe"));
203 await call($, "GAP ui.copy", () => $.ui.copy("lint-guard probe"));
204 await call($, "GAP prompt.read", () => $.prompt.read());
205 await call($, "GAP model.complete", () =>
206 $.model.complete({ model: "haiku", prompt: "Reply with the single word OK", maxTokens: 8 })
207 );
208 }
209
210 // Self-test of the command surface: `$.command.run` queues `/lint` and it
211 // executes once the session is idle, raising our command.run hook. Fired
212 // without await so session.start settles first.
213 call($, "command.run(lint) [queued self-test]", () => $.command.run({ command: "lint" })).then((row) => {
214 rec({ call: "command.run(lint) [settled]", ok: row.ok, result: row.result, error: row.error });
215 });
216
217 await flush($);
218 return next(e);
219 });
220
221 on("turn.complete", async ($, e, next) => {
222 rec({ event: "turn.complete", preview: preview(e).slice(0, 200), seen: { ...seen } });
223 await call($, "session.usage", () => $.session.usage());
224 await flush($);
225 return { text: `${PREFIX} findings so far: ${findings.problems}` };
226 });
227
228 on("session.end", async ($, e, next) => {
229 rec({ event: "session.end", seen: { ...seen }, findings: { ...findings } });
230 await call($, "store.set(lint-guard:findings)", () => $.store.set("lint-guard:findings", { ...findings }));
231 await flush($);
232 return next(e);
233 });
234
235 // ---- the mod's actual job: lint after every Write/Edit ----
236 on("tool.call", { tool: ["Write", "Edit"] }, async ($, e, next) => {
237 const result = await next(e);
238 const file = e.file_path ?? (e.input && e.input.file_path) ?? null;
239 rec({ event: "tool.call[post-lint]", tool: e.tool, file, resultKeys: result && typeof result === "object" ? Object.keys(result) : typeof result });
240
241 if (file) {
242 const { summary } = await lintPaths($, [file]);
243 noteFindings(summary);
244 await call($, "store.set(lint-guard:findings)", () => $.store.set("lint-guard:findings", { ...findings }));
245 await call($, "ui.log", () => $.ui.log(`${PREFIX} ${file}: ${summary.problems} finding(s)`));
246 await call($, "ui.toast", () => $.ui.toast(`${PREFIX} lint: ${summary.problems} finding(s)`));
247 await call($, "ui.status", () => $.ui.status(`${PREFIX} ${summary.problems} finding(s)`));
248
249 // Rewrite the tool result the host is about to record: append our line
250 // to whichever text-ish field the result carries. A frozen result (the
251 // event is deeply frozen; the result may be too) is recorded, not fatal.
252 let rewrote = null;
253 try {
254 if (result && typeof result === "object") {
255 for (const k of ["output", "text", "content"]) {
256 if (typeof result[k] === "string") {
257 result[k] += `\n${PREFIX} eslint after edit: ${summary.line}`;
258 rewrote = k;
259 break;
260 }
261 }
262 }
263 } catch (err) {
264 rec({ call: "tool.result-rewrite", ok: false, error: `result frozen: ${err}` });
265 }
266 if (rewrote) rec({ call: "tool.result-rewrite", ok: true, field: rewrote });
267 }
268
269 await flush($);
270 return result;
271 });
272
273 // ---- interop probe: does the host apply argument rewrites passed to next? ----
274 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
275 if (String(e.command ?? "") !== "echo lint-guard-probe") return next(e);
276 await call($, "ui.notice", () => $.ui.notice(e.tool_use_id, `${PREFIX} checked`));
277 const result = await next({ ...e, command: "echo lint-guard-REWRITTEN-BY-MOD" });
278 rec({ probe: "bash-args-rewrite", expectedIfApplied: "lint-guard-REWRITTEN-BY-MOD", result: preview(result) });
279 await flush($);
280 return result;
281 });
282
283 // ---- the mod-registered tool, served by us ----
284 on("tool.call", { tool: /lint_report$/ }, async ($, e, next) => {
285 rec({ event: "tool.call[own tool]", tool: e.tool });
286 return { result: { ...findings } };
287 });
288
289 // ---- /lint ----
290 on("command.run", { command: "lint" }, async ($, e, next) => {
291 rec({ event: "command.run", command: e.command, args: preview(e.args ?? null) });
292 const { summary } = await lintPaths($, ["src"]);
293 noteFindings(summary);
294 const report = [
295 `lint-guard report — ${new Date().toISOString()}`,
296 `files: ${summary.files} problems: ${summary.problems} (errors: ${summary.errors})`,
297 `last: ${summary.line}`,
298 `events seen: ${JSON.stringify(seen)}`,
299 "",
300 ].join("\n");
301 await call($, "fs.write(.lint-guard-report.txt)", () => $.fs.write(".lint-guard-report.txt", report));
302 await flush($);
303 return { text: `${PREFIX} ${summary.files} file(s), ${summary.problems} finding(s)` };
304 });
305
306 // ---- the band above the prompt ----
307 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
308 seen.uiRender += 1;
309 if (findings.problems === 0 && findings.last === null) return next(e); // nothing to hold: yield the band
310 rec({ event: "ui.render[band]", n: seen.uiRender });
311 return drawBand($, e);
312 });
313}
314