dsh-quality-gate
At turn end it runs the checks the project itself declares; a check that never ran is never recorded as passed.
A dsh plugin: a turn-end quality gate — when the turn edited code or config files, the checks the project itself declares run before the turn closes; a failure goes back as a repair instruction that opens a new turn, under a per-session quota and a total budget.
Contents
- What it does
- Install
- Verify it is live
- What it does not provide
- Enabling it in dsh
- The four gate commands
- Settings
- Public interface
- Data and privacy
- Known boundaries
- FAQ
- Development
- License
What it does
- Turn-end, not in-flight. The hook is the host event
agent/turn-stopping: read this session's successful edits → walk each file up to the nearest manifest root → detect the gate → run it. A failure is injected back viaagent.followup; the host still checks the inbox after turn-stopping, so a repair instruction opens a new turn. - One run has exactly three outcomes:
pass(it really finished with exit 0),code-failure(it ran and reported code errors),not-run(it never ran). A check that did not truly complete is never recorded as passed; and "could not run" is never conflated with "code error" — the former injects "please confirm manually", writes no memory and never reaches the lesson bus asgate-failure. - Only commands the project itself declares. One of four whitelist commands per root; without a declaration the gate is simply not issued — better no gate than an invented command that fails deterministically (see "The four gate commands").
- Runaway protection. Repair injections are quota'd per session across turns; once spent, one user-visible release note is injected and auto-repair stops. The total gate time is budget-capped; a timeout injects a failure instead of passing silently.
- It does not edit code, does not block dangerous commands (that is danger-guard's job) and does not write any file.
Install
dsh plugin --profile web add @jayyuen66/dsh-quality-gate
Remove with dsh plugin --profile web remove @jayyuen66/dsh-quality-gate. The package is on the public npm registry, so installation needs no credentials.
- Requires Node.js
>= 22.12. The artifacts use newer runtime faces such asAbortSignal.any. - Requires a host
dshon the0.2.1-rc.3line. The requirement the host reads isengines.dsh(^0.2.1-rc.3), and it decides alone; pre-release versions participate in range comparison, and0.3.0and later need the explicit, risk-accepting grantdsh plugin --profile <p> allow-version <pkg>@<ver> --dsh-version <exact> --accept-risk. - The runtime dependency
@jayyuen66/dsh-plugin-sharedmust already be on the same registry, otherwise the whole set 404s. typescriptis a realdependenciesentry: the fallback entry for thetscgate — the project's own install wins, then the copy shipped with this package; no network action at runtime, never annpxauto-download.@deepseek-ai/schemasteryis resolved from the host, not installed by this package: only the host's fork resolves.volatile()fields into live references; a public schemastery turns "edit a setting" into "restart to apply". Two validator functions of@deepseek-ai/dsh-credentialsare value imports, so that package sits inpeerDependencies— no@deepseek-ai/*underdependenciesis a publishing contract (test/publish-manifest.test.tsenforces it: a host package there materializes a second copy in the consumer's profile, next to the running host).- Installing a local directory needs pnpm ≥ 12.3.0 (12.0.0 has a known bug in that path).
Shared version floor
If your profile pins @jayyuen66/dsh-plugin-shared with a pnpm overrides entry, that pin must be at least the version this package declares. A lower pin installs a copy without the names this package imports at runtime, and the host then reports N entries did not activate with does not provide an export named ... at boot — the package installs fine and never loads, and no per-repository CI run can see it.
Verify it is live
- The layer is mounted.
dsh --profile web --dump-configends with# == @jayyuen66/dsh-quality-gateplus- id: quality-gate.- That command is the only accepted "in the layer stack" signal, and it is not read-only — it rewrites the profile's root assembly file. Read the layer lines from stdout and check stderr separately: unmatched patch targets only appear on stderr, so the exit code alone proves nothing. Accept both shapes:
# == <layer>and# == <layer>, patched by <path>.
- That command is the only accepted "in the layer stack" signal, and it is not read-only — it rewrites the profile's root assembly file. Read the layer lines from stdout and check stderr separately: unmatched patch targets only appear on stderr, so the exit code alone proves nothing. Accept both shapes:
- The card renders. The plugin list on the settings page shows "quality-gate turn-end quality gate"; change a value, Save goes from disabled to enabled, and the write lands.
- The gate really runs. In a project it recognizes (say a TS project with
package.json+tsconfig.jsonat the root), have the agent edit a code file and end the turn: the gate command runs in that root, and on failure the model receives a repair instruction prefixed[quality-gate], so the turn continues instead of closing. Host log lines with the same prefix carry the command and root. If nothing changes at all, the reading is the layer is not mounted, not "the rules are loose". - Reading "could not run". Environment-class failures (sandbox denial, missing tool, exhausted budget) inject a "please confirm manually" notice, not a repair instruction — that is not evidence of a code error; do not change code because of it.
What it does not provide
- No model tools. The whole surface is the
agent/turn-stoppinglistener; there is nogate_statustool to call. - No HTTP routes and no Remote namespace. The settings card consumes the official
configFormsform and writes through the host's channel — unlike siblings with their own data plane, this package's only data plane is settings. - No CLI subcommand.
- No file writes, no telemetry; the only outbound request is the memory gateway you explicitly enable (see "Data and privacy").
- Optional consumption: the
lessonLoopbus (lesson reports),credentials(key resolution),sandboxPolicy(per-session policy),sessionQuery(event reads),shell(execution). All are optionalctx.getreads — a missing one only removes that capability; the plugin still loads.
Enabling it in dsh
- Bundle form:
dsh.bundle.patchinpackage.jsonpoints atcordis.patch.yml, whose single registration line is- id: quality-gatewithnameset to the bare package name (the host scansdsh.clientonly on bare-name entries);dsh plugin --profile web add/removemaintains that line. - The host half hard-depends only on
settings(default.inject = ["settings"]); precedence is card runtime value > the line'sconfig:> schema built-in defaults. - The client half (
dsh.client.platform = web,immediately: true) injects a card into theplugins.bundle.configslot of the plugin page. Two identifiers, deliberately different sources: the slot key is the bundle package name@jayyuen66/dsh-quality-gate, while the settings namespace andconfigForms.get()use the bare entry idquality-gate— mix them up and the card never renders. - Saving on the card is explicitly staged: edits stay in local pending state until Save writes them serially, key by key, consuming the
Promise<boolean>acceptance bit — the firstfalsestops the loop, showssaveRejectedand keeps the unwritten changes; a rejecting write showssaveFailed. Inputs stay editable during a save, and edits made in flight stay in the pending set instead of being wiped by it; Revert discards the pending edits. Clearing an input means unset, restoring that field's schema default. - Two deployment values have no row on the card and go on the registration line's
config:only, applying on restart:maxRootDepth(root search depth) andgateStdoutMaxBytes(evidence capture cap); defaults in the settings table.
The four gate commands
The whitelist is locked at type level (the GateCommand union in lib/gate-detect.ts): only these four can reach the execution surface; at most one per root, priority workspace > Rust > Python > bare TS.
| Command | When it is issued |
|---|---|
pnpm check |
The root has both pnpm-workspace.yaml and package.json, and the root scripts declare a non-empty check. Otherwise it falls back to the next candidate |
cargo check |
The root has a Cargo.toml |
ruff check . |
The root has a ruff.toml, or a pyproject.toml with a [tool.ruff] section. A bare pyproject.toml does not count — it is Python metadata, not a ruff opt-in |
tsc --noEmit |
The root has both package.json and tsconfig.json; without tsconfig.json it is not a TS project |
tscis only a logical name in the command: the entry is resolved at runtime — the project's owntypescriptfirst, then the copy shipped with this package, finallytscon PATH (reported as not-run by the "tool unavailable" exit); never annpxauto-download.- Root detection: from each edited file, up to
maxRootDepth(default 10) levels to the nearest manifest root; a monorepo package is upgraded to the workspace root holdingpnpm-workspace.yaml— but only when that root yields a gate; otherwise the package keeps its own root (an outer workspace without acheckscript must not swallow the package'stsc/ruffgate). - Budget and concurrency: total budget
gateBudgetMs; each root gets the smaller ofmax(10s, budget / root count)and the time actually left; a tail with under 5s left does not start and is injected as "did not run". Gates run sequentially and only one failure is injected at a time. - Evidence caps: 64 KiB per stream stays in memory (the tail is kept; the first error is the root cause), and when truncated a bounded 4 KiB head excerpt is read back from the spill file; the per-session edit accumulator holds 500 files, evicting oldest-first with a warning.
Settings
The namespace quality-gate is the settings entry id; the fields projected into the form are exactly the .volatile() ones.
| Field | Type | Default | What it does |
|---|---|---|---|
enabled |
boolean | true |
Master switch; off means no turn-end verification at all |
maxInjectsPerTurn |
natural, 1–5 | 2 |
Repair-injection quota, accumulated per session across turns |
gateBudgetMs |
natural, 10000–600000 | 300000 |
Total gate budget; a timeout injects a failure instead of passing silently |
memoryFeedback |
boolean | false |
Additionally persist code-level failures to the memory gateway |
memoryGatewayUrl |
string | "" (unset) |
Gateway base URL; the setting wins, empty falls back to the env TDAI_GATEWAY_URL |
memoryGatewayKeyRef |
string (credential ref name) | TDAI_GATEWAY_KEY |
Ref name of the gateway key; the value lives in the host credential service, never in settings |
maxRootDepth |
natural | 10 |
Max levels of upward root search (deployment value, no row on the card) |
gateStdoutMaxBytes |
natural | 65536 |
Evidence capture cap per stream (deployment value, no row on the card) |
Settings persist in the host's settings file under the dsh data directory; the card's save semantics are in "Enabling it in dsh".
Public interface
- No HTTP surface: this package registers no routes or endpoints and offers no interface for external calls.
- To the host: the settings namespace
quality-gateis registered implicitly (namespace = entry id; editable fields are the.volatile()ones of the exportedConfig; there is nosettings.registercall), and the schema and the registration line'sconfigshare that oneConfig. The page policy is declaredauto: false— this package ships its own card, so the host must not generate a second, automatic form. - Injected followups are
role: "user"withsource: { kind: "plugin:quality-gate" }and an id built as the prefixquality-gate-plus a UUID. - The text injected into the model comes in four kinds, every message carrying a
[quality-gate]prefix plus command and root context: a repair instruction; a "could not run" notice (with the budget-starved skip list embedded); a release note once the quota is spent. - Logging goes through the host's named logger (the
info/warn/errorface), falling back toconsoleonly when that service is absent. - Language: host-side text follows the official
localesettings preference, defaulting to Chinese when unregistered and taking effect on the next turn; the card's own text goes through the official client locale, and a language switch re-renders the slot with no page reload. - To the bus (when lesson-loop is installed):
report({ source: "quality-gate", category: "gate-failure" | "gate-not-run", cwd, sessionId, turn, signature: the command string, detail: the full evidence, evidence: { root, command, kind } }). When the bus is absent the report is simply dropped and the gate keeps working; thememoryFeedbackswitch does not affect bus reporting. - Optional outbound call: a
POST /v2/conversation/addtomemoryGatewayUrl, under a hard 5s timeout. Headers carry a Bearer token andx-tdai-service-id(envTDAI_SERVICE_IDoverrides, defaultdefault); the body issession_id: "dsh-agent"plus oneassistantmessage, whoseagent_idis the cwd's tail directory name with an 8-hex sha256 prefix (same derivation as lesson-loop's project buckets).
Data and privacy
| Aspect | Facts |
|---|---|
| Zero outbound by default | memoryFeedback off plus an empty URL are a double lock — with either empty nothing is pushed and the credential store is not even queried |
| What leaves the machine | When enabled, only the command, the project root and the full error text (single-lined, untruncated) — sent to your own gateway |
| URL and credential safety | Credentials go to https or loopback addresses only (localhost, 127.0.0.0/8, IPv6 loopback); plain http to a non-loopback host is refused as unconfigured, with the reason stated. A base URL that does not parse as a legal http(s) URL counts as unconfigured too |
| The key | The value lives in the host credential service (a credentials file under $DSH_HOME); settings store only the ref name, and a ref name that is not a valid POSIX variable name counts as unconfigured |
| Failure handling | Push failures only warn, at most once per load, and never block the turn |
| Disk reads | Manifest presence, the root package.json scripts, pyproject.toml section headers, the head of a spilled truncated file; no file writes, no telemetry |
| Memory | Session ledgers (edit accounting / injection quota / event cursors) live in process memory and are cleared when the plugin unloads |
Known boundaries
- The whitelist has exactly four commands and cannot be extended via settings. Roots checked by other tools (
go test,mypy,eslint, …) get no gate. - Only successful edits count, and only writes through
edit/write/str_replace_editor(viewis read-only). Files outside the extension whitelist (md/txt/images/.sh) are not counted — none of the gate commands can validate them, and a.shwould only drag the root resolution somewhere else and produce a false failure. - Out-of-process subagents (ACP / claude-code / codex) emit no dsh events, so they are outside the gate by construction; in-process subagents are skipped and their edits are verified by the parent session's next turn.
- Never talks over the user: no injection when the inbox has pending items, the turn signal is aborted, or the plugin is unloading. Plan mode is not exempt — a planning turn that touched code is gated the same way.
- When evidence is truncated with no spill file, the first error may already be gone; the text says the evidence is incomplete instead of pretending otherwise.
- When the sandbox policy cannot be resolved, no gate runs at all (injected as not-run); it never falls back to the deployment-level policy — that would run the gate outside the sandbox, get it denied, and produce a false repair instruction.
FAQ
| Question | Answer |
|---|---|
Why did my pnpm project not run pnpm check? |
Its root package.json declares no non-empty check script — a deliberate trade: a false failure costs more than a missed check |
| The gate said "could not run" — is my code broken? | No. Sandbox policy denial, a runner that will not start, a timeout, a cancellation, exit 126/127 and an exhausted budget all land here. The injected text explicitly says do not change code because of it, and it is reported separately as gate-not-run |
| Can it loop forever? | Once the quota is spent it injects one user-visible release note, stops auto-repair, and only counts again after a fully clean pass |
| Stop it temporarily? | Turn off enabled on the card, or run dsh plugin --profile web remove @jayyuen66/dsh-quality-gate |
Which tsc is used? |
The project's own install first, then the typescript shipped with this package, and PATH only as the last resort — never an npx download |
| What about code edited by subagents? | In-process subagents are covered by the parent session's next turn; out-of-process ones emit no dsh events, so they are outside the gate by construction |
Development
pnpm install
pnpm run check # typecheck (three tsconfigs) → lint → client build → host build → tests (with coverage) → tarball inspection → format
- Coverage is measured over
host.tsandlib/**only, with all four thresholds at 100%.src/is not untested:test/client-card.test.tsdrives the card wiring and both locale dictionaries headlessly (ui-messages.tsis fully executed), but most ofclient-entry.tsis the render surface, which needs a DOM harness this package deliberately omits (react-dom/jsdomare not pulled in). Counting all ofsrc/would turn the thresholds into a permanent red, so they are pinned to the surface that is fully verified. test/publish-manifest.test.tsenforces the publishing shape: entry points resolve, no@deepseek-ai/*underdependencies, no dates or local paths in the artifacts, with comments and documents scanned the same way.test/dist-size.test.tsguards the byte budget of both artifacts;check:packinspects a realpnpm packtarball.
License
MIT. Source at github.com/JayYuen666/dsh-quality-gate.
The mechanism is ported from ECC (MIT, © Affaan Mustafa) — turn-end checks the project itself declares, and edit accounting. THIRD_PARTY_NOTICES.md records the upstream origin, what this package built on top of it, and the runtime-dependency boundary.
No comments yet. Be the first to write one.