dsh-rules
OMP rules for DeepSeek Harness.
Discovers rule files from every convention OMP supports, injects the always-apply and rulebook layers into the system prompt, lets the model load a rule body on demand, and enforces time-traveling stream rules that interrupt violating model output mid-turn.
Install
dsh plugin --profile <name> add @ndsanes/dsh-rules
Then add the row to the profile's cordis.patch.yml if it is not already
applied by the package's own bundle patch, and reload the profile.
If the profile already carries this plugin under a different package name — a
stale link: dependency or a dsh.profile.bundles entry — remove it from
both dependencies and dsh.profile.bundles before adding. The loader
resolves a bundle row by the current package name, so a leftover entry points
at a module the profile no longer has, and the boot reports only
import failed. Nothing on the plugin side can diagnose that.
Rule files
A rule is a Markdown file with YAML frontmatter:
---
description: Read before writing a database migration.
globs: "Database/**/*.surql"
condition: "DEFINE (FIELD|TABLE|INDEX)"
scope: "tool:edit(*.surql), tool:write(*.surql)"
interruptMode: always
---
The migration body.
| Field | Meaning |
|---|---|
description |
Required for the rulebook listing. |
alwaysApply |
Injects the whole body into the system prompt. |
globs |
Path gate; a TTSR rule needs at least one matching candidate path. |
condition |
Regex triggers (legacy ttsr_trigger also accepted). |
astCondition |
ast-grep structural triggers, checked on tool arguments. |
question |
Natural-language question answered by a judge model. |
scope |
text, thinking, tool, tool:<name>(<glob). |
agents |
Agent-name globs; main and sub are reserved. |
interruptMode |
never | prose-only | tool-only | always. |
In the rule editor, a field with a fixed vocabulary — interruptMode today —
is a dropdown listing exactly the accepted values, so the value cannot be
misspelled. That matters because an unrecognised interruptMode is not
rejected: it falls back to the profile default, and the default is always.
The dropdown's first option means "inherit the profile default", and choosing it
removes the line from the file. An empty value would parse as unrecognised
and land on the same default it was meant to escape.
Write regexes with single-quoted YAML scalars. A double-quoted scalar treats
\s, ( and (-family sequences as YAML escapes, which makes js-yaml reject
the whole document. The plugin then falls back to reading the block line by line
and recovers the value anyway, so the rule still loads and still matches — but
the recovery cannot unescape a sequence YAML already refused, which is why the
single-quoted form is the one to write.
Discovery
| Provider | Priority | Sources, most specific first |
|---|---|---|
native |
100 | <cwd>/.omp/rules/*.md(c), <userRulesDir>/rules/*.md(c), sticky RULES.md |
omp-plugins |
90 | rules/ under each configured pluginRoots entry |
agents |
70 | .agent/rules, .agents/rules, project walk then user |
cursor |
50 | <cwd>/.cursor/rules, ~/.cursor/rules |
windsurf |
50 | <cwd>/.windsurf/rules, ~/.codeium/windsurf/memories/global_rules.md |
cline |
40 | nearest .clinerules, file or directory |
github |
30 | .github/instructions/*.instructions.md, applyTo normalized |
builtin-defaults |
1 | OMP's bundled rules, shipped in src/builtin-rules/ |
Order within a row is load-bearing: identity is the rule name alone and the
merge keeps the first rule to claim it, so a user's ~/.cursor/rules/style.md
loses to the workspace's <cwd>/.cursor/rules/style.md of the same name.
Bundled rules
src/builtin-rules/ holds OMP's 27 default rules — TypeScript, Go, and Rust —
verbatim, MIT licensed, under the same names, so a rule file in either harness
overrides the other. They are the source of truth; pnpm run embed:rules
inlines them into src/generated/builtin-rules.ts because the bundler cannot
import Markdown as text. Every one is a TTSR rule scoped to a file type, and
none of them blocks a write. The Go and Rust rules trigger on regular
expressions over the reconstructed source; the three that rely on astCondition
are subject to the grammar limit noted under Differences from
OMP.
The Plugins page
dsh-rules ships a browser half. The same two sections appear twice: on the
Plugins page under this plugin's own entry, and as a tab under
Settings → Plugins, which is where a reader goes to configure a plugin. Both
render the same component; the Settings tab carries no plugin subject, so the
section draws unconditionally there.
The plugin's own configuration — enabled, userRulesDir, pluginRoots,
copilotInstructionDirs, and the ttsr block — is not drawn by this plugin at
all. The host derives a form from the Config schema and renders it against the
profile row id, which is why the switches live in Settings even on a deployment
that never opens this panel.
Rule audit — the numbers first, then the detail:
| Stat row | rules found, in force, switched off, deliveries here, deliveries elsewhere, never fired |
| Proportion | where rules come from, split by source convention |
| Proportion | what is in force |
| Proportion | what makes them fire — condition, ast-grep, question |
| Proportion | how delivery is spread — never fired, 1 time, 2 times, 3 times, 4 or more |
| Ranking, folded | per-rule delivery detail, behind a summary that names how many rules are in it |
The first four are composition, not ranking: one full-width bar split by share
with a legend beside it. Drawing a separate bar per category was strictly worse
than reading the numbers — the dominant category filled its whole track and the
minor one was a stub, so length carried nothing the label did not. dsh's web
client ships no charting library, and dsh-usage-chart — the one community
plugin that draws charts — documents the same conclusion: a self-drawn SVG that
matches the platform's own rendering is smaller and steadier than a vendored
library.
The delivery chart is the one that used to be a ranking, and a ranking was the
wrong form for it. It grew a row per triggered rule, its scale was set by
whichever rule had been delivered to most often, and on a real profile that
winner is five — so the page showed a field of one colour whose length said
which rule got lucky, and said nothing about whether delivery works at all. The
chart is now the distribution of delivery counts in five fixed buckets, so two
profiles stay comparable and 4 or more absorbs the tail: one runaway rule can
neither stretch the scale nor add a sixth bucket. Its legend counts rules, not
deliveries, and the chart says so under the legend, because the two read the
opposite way round. Nothing delivered yet is a sentence rather than one bucket
at a hundred percent.
The per-rule ranking is still there, folded behind Delivery detail (N rules).
It answers the narrower question — which rule — for the reader who has to go act
on one, and it is neutral rather than red: a high count is not a fault, and
painting the whole panel for one delivered rule made the colour mean nothing.
Delivery counts are persisted to $DSH_HOME/dsh-rules/triggers.json, because a
count held only in memory reads zero in every process that did not itself
deliver a rule — and sessions run in the web app, the CLI, and one-shot runs
alike. A delivery is one rule actually reaching the model: an interrupt, a
reminder folded into a tool result, a denied call, or a judged warning.
The ledger is per profile while the rule set is per workspace, so a count can
name a rule this workspace never discovered. The page keeps the two apart —
deliveries here counts only rules on screen, and anything else shows as
delivered elsewhere rather than being folded into the workspace's numbers.
Below the charts, the detail list:
| Control | What it does |
|---|---|
| Filter by source | one chip per convention, with counts: Bundled with the plugin 27, .omp/rules 2, and any other provider the profile contributes |
| Filter by text | matches rule name, description, and source path |
| Per row | name, whether it is in force, its description, and source · path |
| Inactive rows | say why, as in off · listed in ttsr.disabledRules |
The Host sends a stable reason code (disabled, builtins-off, agent-filter,
no-trigger, shadowed) and the page localizes it; a code the page does not
recognize is shown verbatim, never flattened into "unknown". The rule
tool spells the same codes out in English, because its reader is a model.
Bundled rules — the 27 shipped rules, grouped by family (ts 13, go 8,
rs 6) with a text filter, on the same page below the audit. Each row has its own on/off switch, which is the direct action; the checkbox beside it only selects, and the bar below acts on the whole selection at once — Select all, Invert selection, Disable N selected, Enable N selected`.
The plugin leaves the keyed plugins.row.config slot alone: that is the
platform's own configuration form for this entry, and taking it would strand
every setting that is not a toggle. Reads come from the audit; writes go through
the configuration form, which owns the profile patch. The ttsr block is volatile, so a change applies on the next
step rather than the next restart, and the audit rebuilds itself when it sees
the configuration has moved.
The page draws itself from --dsw-* tokens rather than importing dsh's client
packages: those are versioned on the client line, and pulling one into a host
package's dependency tree drags a second copy of the session projection along.
Managing rules
The rule tool is the management surface.
| Call | Effect |
|---|---|
{"name": "x"} |
Load one rule's body. |
{"action": "list"} |
Every rule, its state, and why an inactive one is off. |
{"action": "disable", "name": "x"} |
Turn a bundled rule off. |
{"action": "enable", "name": "x"} |
Turn a bundled rule back on. |
Toggles are written to the profile patch through the settings service, so they
persist across restarts, and the ttsr config block is volatile, so they apply
on the next step rather than the next restart. When the deployment has no
settings service, the tool says so and prints the exact YAML to add. It never
claims a change it did not make.
Only bundled rules are toggleable. A user or project rule is governed by its own file, and the tool points you at that file rather than editing it for you.
The file-based equivalents are ttsr.disabledRules (a list of names) and
ttsr.builtinRules: false (all of them).
Identity is the rule name alone, so a name claimed by a higher-priority provider
wins and the loser is dropped. Sticky RULES.md files always share the name
RULES and are always applied; the user sticky therefore shadows the project
one, and a rules/RULES.md shadows both.
The three layers
- rulebook —
- <name> (<globs>): <description>in<domain-rules>. The body costs no resident context; the model loads it by name. - always-apply — the whole body in
<generic-rules>. - TTSR — a rule with any trigger field. It is registered, leaves the other two buckets, and is enforced while the model is still writing.
Addressing a rule
dsh has no internal-URL protocol registry, so the rule://<name> address is
served by a model-facing tool. The prompt advertises rule://<name>; the model
calls rule with that name and receives the Markdown body. An unknown name
answers with the list of addressable rules.
The snapshot covers all three buckets, so a triggered TTSR rule stays re-readable after it fires.
Writing a rule
The tool also writes. When the user states a constraint that will still hold
next week, the system prompt tells the model to write it down rather than only
obey it once: call rule with action: "create", a name, a frontmatter
block and a body, and the file lands in whichever directory that scope
resolves to — see below. It joins the audit like any other project rule, so it
can be edited or deleted from the panel.
Five things are refused:
- A name that is a path. The name becomes a filename, so
../, a path separator, a leading dot or a character the filesystem reserves all stop it before anything is joined to a path. - A name a rule already holds. Two files claiming one name is a configuration that silently applies only one of them.
- An empty body. There would be nothing for the model to read.
- Frontmatter carrying no settings at all. The file would load with no trigger and no description, join no bucket, and never reach the model. The bar is otherwise deliberately low: a block js-yaml rejects is read back by the frontmatter reader's line-by-line recovery, so malformed quoting still yields a rule that applies and is not refused.
- An existing file. The create is exclusive, checked on disk rather than against the discovered set — a rule that failed to parse is absent from that set and would otherwise be overwritten without a word.
The result tells the model to report where it wrote, because the file becomes the user's to review.
Which directory a rule lands in
Two conventions are in play. OMP keeps project rules in <cwd>/.omp/rules and
user rules in ~/.omp/agent/rules. dsh has no rules directory of its own — it
reads <cwd>/.dsh/AGENTS.md, <cwd>/.dsh/skills and the matching paths under
$DSH_HOME, and nothing rule-shaped — so this plugin defines <cwd>/.dsh/rules
and $DSH_HOME/rules, following that layout.
The choice is OMP-first, per scope:
| Scope | OMP directory | dsh directory |
|---|---|---|
| project | <cwd>/.omp/rules |
<cwd>/.dsh/rules |
| global | <userRulesDir>/rules |
$DSH_HOME/rules |
A scope that already holds at least one rule under OMP keeps receiving OMP
rules; one that holds none gets the dsh path. An existing but empty .omp
directory does not count — it may belong to an unrelated tool. The two scopes
resolve independently, so an OMP project does not make your global rules OMP.
Both directories are read by discovery, so a rule is governed the same way wherever it sits.
Migrating between the two
rule with action: "migrate" moves every rule from one directory to another.
Two axes are independent, so all four moves are expressible:
| Move | Arguments |
|---|---|
.omp → dsh, same scope |
toConvention: "dsh" |
dsh → .omp, same scope |
fromConvention: "dsh", toConvention: "omp" |
| project → global | toScope: "global" |
| global → project | scope: "global", toScope: "project" |
Both destination arguments are optional and default to leaving that axis
alone: omit toScope to keep the scope, omit toConvention to keep the
convention. A move that changes one axis takes one argument; both can change at
once, which is a real arrangement and works like any other.
A migration is a change of directory, not of meaning: both sides are read by discovery, so the name, the body and what the rule does are untouched. Three things are refused:
- A name already taken at the destination. Two files claiming one name means only one of them applies, and the loser is invisible in the audit, so the existing rule wins and the other file stays put.
RULES.md. Sticky rules resolve by the directory they sit in, so moving the file would silently change which workspace it governs.- A same-directory move, which would otherwise report a file as moved onto itself.
Files land by rename where the two paths share a filesystem, and by
copy-then-delete where they do not — a project on an external drive against a
home directory on the internal one fails EXDEV otherwise. The source is
removed only after the copy succeeds, so an interrupted move leaves the rules
where they were. Every file is reported afterwards: what moved, and what was
left alone.
Enforcement
On agent/assistant-stream, every text, reasoning, and tool-argument delta is
matched against the eligible rules.
- A match whose
interruptModeallows it callsagent.cancel({ kind: 'hook', reason }, { keepInbox: true })immediately, then immediately steers the rule body back in, so the next turn the loop runs starts with the rule in context. - A prose match that does not interrupt is delivered as a reminder after the assistant message completes.
- A tool match that does not interrupt folds a
<system-reminder>into that call's own result throughtools/post-execute, ahead of the tool's content and preserving it verbatim. tools/pre-executedenies a call whose reconstructed source violates a rule whose interrupt mode covers the tool surface, so a violation never reaches the filesystem. A denial spends nothing on the repeat ledger: refusing a write is not a delivery, so arepeatMode: oncerule keeps refusing instead of opening on the second attempt.
Tool-supplied paths are matched in every form a rule might have written them:
the literal argument, its workspace-relative form, and its basename. Without
that, a rule scoped to a Docs prefix would silently match nothing when a tool
names the file by absolute path.
questionrules are asked after an output completes and can only warn.
Defaults match OMP: interruptMode: always, repeatMode: once,
repeatGap: 10, builtinRules: true, contextMode: keep.
repeatMode governs delivery — interrupts, reminders, and judge warnings. A
pre-execute denial is not a delivery and is never suppressed by it.
Configuration
- id: dsh-rules
name: '@ndsanes/dsh-rules'
config:
enabled: true
userRulesDir: ~/.omp/agent
pluginRoots: []
copilotInstructionDirs: []
ttsr:
enabled: true
interruptMode: always
contextMode: keep
repeatMode: once
repeatGap: 10
builtinRules: true
disabledRules: []
judge: auto
judgeProvider: ''
judgeModel: ''
The three path settings accept a leading ~ and expand it to the user's home
directory before discovery reads them; everything else is taken literally.
Differences from OMP
rule://is a tool, not a URL protocol. dsh has no internal URL registry, so the addressing form lives in theruletool contract.- Interrupt and reminder deliveries are user-role messages. dsh composes a
step's system prompt through the section registry, so a message injected
mid-turn is model-visible but not system-authoritative. A model asked to
repeat a forbidden word, then told the rule through a reminder, has been
observed weighing the two and answering with the forbidden word anyway — that
is the
interruptMode: neverpath behaving as specified, not a failure to deliver. Treat the reminder as strong guidance, not as a guarantee. contextMode: discardis not implemented. dsh commits partial assistant output durably on abort and offers no way to remove it, so the default iskeepand the interrupted partial message stays in history.- The judge is a configured route, not a role. OMP resolves
judgeto a native System One model and reads a probability. HerejudgeProviderandjudgeModelmust both be set; the judge answers YES/NO per question and an unparseable answer counts as "not violated". matcherPaths/matcherDigestdo not exist. Candidate paths come from the tool arguments, and the source snapshot from the arguments a write, edit, or multi-edit would produce.- AST grammars are the bundled ones, and Go/Rust are not among them.
@ast-grep/napi@0.45ships TypeScript, Tsx, JavaScript, HTML, and CSS only —parse('Go', src)throwsGo is not supported in napi, and the plugin does not register dynamic languages, so the three bundled Go rules carryingastCondition(go-range-int,go-bench-loop,go-new-expr) cannot fire here. Their patterns stay on the rule and each records a warning, so they register as streaming rules and state their own limitation; a path whose grammar is unknown is skipped, never mis-parsed. The native addon loads on first AST match inside a guard, so a platform without a prebuild costs that one surface and not the plugin's load. - Toggles land in the profile patch. The
ruletool writes through the settings service, so a deployment that does not mount one falls back to printed YAML.settingsNamespacenames the profile row; it defaults todsh-rules, matching the shippedcordis.patch.yml. - Injection state is per session. Injected rule names are not persisted to
the session log, so a reload makes a
repeatMode: oncerule eligible again.
Late mounting
A plugin can mount into a harness whose agents already exist — a web app that
was already running, or a session that outlived a reload. agent/created is the
warm path, not the only one: every surface that can name an agent builds that
agent's session on first use, so a pre-existing agent is governed from its next
step; it never runs rule-free.
Two consequences worth knowing:
- Prompt assembly and stream frames cannot await discovery, so the very first assembly after a late mount renders without the rule layers while the build runs. The next assembly includes them.
tools/pre-executeis an async waterfall, so the first tool call waits for discovery and is then evaluated against the rules. A violation in that call is still blocked.
The rule tool distinguishes "still being discovered" from "no rules exist",
so a session that predates the mount says so, rather than looking like a broken
rule directory.
Development
Branches carry the release decision: work lands on dev, and merging dev
into main is what ships. The Release workflow watches main, so the merge is
the approval — there is no separate publish button to press.
Publishing uses npm Trusted Publishing, so the workflow holds no token. GitHub
vouches for the run with a short-lived OIDC credential that npm exchanges for a
publish token scoped to that one run. Configure it once on npmjs.com under the
package's Trusted Publisher settings: user Ndsanes, repository dsh-rules,
workflow filename release.yml, with npm publish among the allowed actions.
pnpm install
pnpm test # vitest
pnpm typecheck
pnpm build # embed rules, tsc, host bundle, client bundle + loader envelope
pnpm run embed:rules # after editing anything in src/builtin-rules/
scripts/audit-rules.ts <project> prints what the plugin parsed from a real
rule set, which is how backward compatibility against an existing .omp
directory is checked:
npx tsx scripts/audit-rules.ts /path/to/project
scripts/render-prompt.ts <project> mounts the plugin on a real dsh
system-prompt service and prints the prompt the model would receive. It needs
no model call, so it verifies prompt injection on a machine whose provider
cannot be reached:
npx tsx scripts/render-prompt.ts /path/to/project
scripts/verify-interrupt.sh [dsh-home] [fixture] runs one prompt twice against
a live dsh — once with a rule that interrupts, once with an identical rule that
only warns — and prints both transcripts so the difference is the evidence. The
fixture is a directory holding .omp/rules/no-banana.md and
.omp/rules/no-banana-quiet.md; both are parked and restored on every exit
path, so an interrupted run cannot leave it with neither rule:
scripts/verify-interrupt.sh /path/to/isolated/dsh-home /path/to/fixture
tests/interrupt-loop.spec.ts runs the same scenario offline: it mounts a real
AgentLoop, SessionStore, LlmRuntime, and tool registry through the dsh test
kit, points them at a scripted adapter that violates a rule on its first reply,
and asserts the abort, the <system-interrupt> retry, the <system-reminder>
path for interruptMode: never, and that repeatMode: once spends the rule.
License
MIT
No comments yet. Be the first to write one.