Fast structural enforcement, before the linters.
Dictator is a pre‑linter structural gatekeeper for your codebase. It doesn't replace RuboCop, ESLint, or Clippy — it runs before them. While those tools analyze code quality, Dictator enforces the boundaries: file structure, naming conventions, ordering, and basic hygiene.
Canonical lore (Timeline 7) says Dictator was manifested in Rust 60 and then backported to Rust 1.91. In this timeline, it is a conventional Rust crate that you build and run with a modern stable toolchain.
Think of it as border control for your codebase: everything must satisfy basic structural discipline before the expensive tools run.
- Run fast, structural checks before slow linters.
- Enforce file/line limits, naming, ordering, and hygiene.
- Drive LLM workflows, CI, and monorepos with one config:
.dictate.toml. - Extend via WASM decrees and an MCP server for AI assistants.
Install the published crate (fastest):
cargo install dictatorTo force the latest release and respect the lockfile:
cargo install dictator --lockedDownload and install a pre-built binary for your platform:
curl -fsSL https://raw.githubusercontent.com/seuros/dictator/master/scripts/install.sh | bashThis installs dictator to ~/.local/bin by default. Make sure this directory is on your PATH:
export PATH="$HOME/.local/bin:$PATH"Installation options:
--prefix <dir>— Install to a custom directory--version <tag>— Install a specific release version--help— Show all options
Example:
curl -fsSL https://raw.githubusercontent.com/seuros/dictator/master/scripts/install.sh | bash -- --prefix ~/.cargo/binRequires Rust 1.98 or newer. The repository pins Rust 1.98.1 for local and release builds.
cargo install --git https://github.com/seuros/dictatorOr from this repository:
cargo install --path crates/dictator- Linux: glibc 2.31+ (most modern distributions)
- macOS: 11.0+ (Intel or Apple Silicon)
Expensive linters are slow. Running RuboCop on a large Rails codebase takes minutes. ESLint on a monorepo crawls.
LLMs generate structural chaos. Claude creates 300 files. All compile. All have wrong structure: inconsistent naming, files with 2000 lines, frontmatter fields in random order, private methods in wrong positions.
You need fast boundary checks first. Catch structural violations in milliseconds, not minutes. Then run the expensive linters on code that already passes basic discipline.
File boundaries:
- Maximum line count (ignoring comments)
- Trailing whitespace, tabs vs spaces
- Final newline presence
- Line ending consistency (LF vs CRLF)
Naming conventions:
- Folder names (kebab-case, snake_case, etc.)
- File names matching patterns
- Function/class name conventions
Ordering discipline:
- Frontmatter field order (slug after title, pubDate in position 2)
- Method visibility sections (public → protected → private)
- Import/require statement grouping
- YAML/TOML key ordering
Basic hygiene:
- No emojis in source code (structural noise)
- Copyright/license header presence (structural requirement)
- Comment formatting (
#foo→# foo)
What Dictator does NOT do:
- Code quality analysis (use RuboCop, ESLint, Clippy for that)
- Context-dependent enforcement (focus markers, risky patterns—might be legitimate)
- Type checking (use your language's type system)
- Complexity metrics (use dedicated tools)
- Performance analysis
Dictator checks structure. The VIPs (RuboCop, ESLint) check quality and context.
Speed. Dictator runs structural checks in milliseconds. Catch obvious violations instantly without waiting for heavy linters.
LLM workflows. When Claude generates 300 files, Dictator validates structure immediately:
Claude generates code → Dictator checks structure → Fix → RuboCop checks quality → Done
CI optimization. Fail fast on structural violations before expensive linter passes:
git push → Dictator (2ms) → ❌ File too long → Fix locally
git push → Dictator (2ms) → ✓ → RuboCop (45s) → ✓ → Deploy
Monorepo enforcement. One binary, one config, all languages. Consistent structural rules across Ruby services, TS frontends, YAML configs.
Git-aware filtering. Dictator enforces .gitignore boundaries. Your build artifacts, dependencies, and editor cruft don't exist to Dictator. target/, node_modules/, .DS_Store—invisible.
Dictator uses the ignore crate (from ripgrep) for full git semantics:
- All
.gitignorefiles in the hierarchy (parent dirs, subdirs) .git/info/exclude(per-repo exclusions)- Global gitignore config (
core.excludesFile)
Outside git repositories: Dictator still works. It just won't find gitignore files to respect. If you're not in a git repo, every file is visible.
Overriding gitignore: Explicit file paths bypass gitignore. dictator lint target/debug/foo.rs lints that file even if target/ is ignored. Directories respect gitignore. Single files don't negotiate.
Territorial enforcement. Dictator can critique any file in the filesystem. dictator lint /etc/nginx.conf reports violations. dictator dictate /etc/nginx.conf modifies the file—you're in control via CLI.
Via MCP (AI assistants), destructive operations are restricted to the working directory and require a git repository. This prevents your helpful-but-misaligned AI from "fixing" /etc/passwd or reformatting your entire home directory because it detected trailing whitespace. You won't get "Oops 😅, my bad. Let me reformat your computer." Dictator critiques other states' policies but won't intervene outside project boundaries when an LLM is driving.
Rules are meant to be broken. That's why you have 1000 linters with 10000 rules and everyone disables half of them.
Decrees are absolute. The Dictator does not negotiate. Your file ends with a newline or it doesn't pass. Your methods are ordered correctly or they aren't. No "warn", no "suggestion", no "consider maybe perhaps".
This is structural discipline, not style advice.
Your blog posts need consistent frontmatter. LLMs swap field order randomly:
---
pubDate: 2025-12-01
title: "My Post"
slug: my-post
---Dictator enforces order:
---
title: "My Post"
slug: my-post
pubDate: 2025-12-01
---Compiles either way. Dictator doesn't tolerate the first. Structure is not negotiable.
LLMs generate comments without proper spacing. Dictator catches it:
#bad comment # ❌ Missing space after #
# good comment # ✓ CorrectDictator auto-fixes #bad → # bad. RuboCop checks style. Dictator checks structure.
.dictate.toml (decree configuration)
↓
dictator (Rust CLI, single binary)
↓
dictator-core (wasmtime, parallel execution)
↓
dictator-decree-abi (shared ABI: Plugin/Diagnostic types)
↓
WASM decrees (decree.supreme, decree.ruby, decree.golang, ...)
↓
Diagnostics (JSON/SARIF/stdout)
Decree-driven enforcement. .dictate.toml declares which decrees are active. Dictator loads corresponding WASM components and runs them in parallel.
All WASM. Every decree is a WASM component:
decree.supreme: Universal structure (spacing, whitespace, line endings)decree.<language>: Language-specific structure (method ordering, naming, conventions)
Why WASM:
- Sandboxed (decrees can't corrupt Dictator)
- Distributable (share company-specific decrees)
- Extensible (add new languages without rebuilding)
- Isolated (no dependency conflicts)
Decree Versioning. Every decree exports metadata including ABI version:
- Dictator validates decree compatibility at load time
- Incompatible decrees fail fast with clear errors
- Pre-1.0: decrees built with different ABI versions won't load
- Future-proof: supports API evolution (fix(), streaming, config at lint-time)
Speed first. No heavy AST parsing. Pattern matching, line counting, regex. Fast enough for watch mode.
At a high level:
-
You point Dictator at some paths.
dictator lint .,dictator watch sandbox/, or whatever mess your LLM or your team just hallucinated. -
It reads
.dictate.toml. This is the decree book. It decides:- which decrees are enabled (
decree.supreme,decree.ruby,decree.typescript, …) - what the limits are (max lines, allowed line endings, naming rules, etc.)
- which decrees are enabled (
-
It walks the filesystem. Dictator does a fast pass over the files you pointed at: no ASTs, no type-checking, just “what files exist, what are their extensions, how big are they”.
-
It assigns each file to decrees.
- Every file is judged by
decree.supreme(whitespace, line endings, length, final newline). - Language files get additional judges:
*.rb→decree.ruby,*.ts→decree.typescript,*.go→decree.golang, etc.
- Every file is judged by
-
It runs all decrees in parallel as WASM. Each decree is a sandboxed WASM component. Dictator:
- feeds it the file contents + config
- waits for diagnostics (violations) to come back
- never lets decrees touch your filesystem or spawn surprise subprocesses
-
It surfaces diagnostics. Dictator reports:
- file + line + column
- which decree complained
- a short, rude description of what you did wrong (trailing whitespace, file too long, wrong visibility order, etc.)
-
(Optional) It fixes what it can. In auto-fix modes, Dictator will happily:
- strip trailing whitespace
- normalize line endings
- add missing final newlines and then leave the harder, semantic work to your “real” linters.
The entire pipeline is “cheap first, expensive later”: Dictator slaps your structure into shape, then your quality linters and type-checkers show up once the room is already clean.
Dictator reads .dictate.toml, loads the configured decrees (WASM components), and runs enforcement.
# Initialize config (creates .dictate.toml)
dictator occupy
dictator init # alias
# Lint files (read-only, reports violations)
dictator lint src/
dictator stalint src/ # alias
# Fix structural issues (trailing whitespace, CRLF→LF, final newline)
dictator dictate src/
dictator kjr src/ # alias
# Watch mode (re-check on every save)
dictator watch .
# Specify custom config
dictator --config .dictate.dev.toml lint src/
# Only what this branch touched (see "Diff-Scoped Enforcement" below)
dictator lint --diff origin/master
dictator lint --staged| Command | Alias | Mode | Description |
|---|---|---|---|
occupy |
init |
Setup | Creates .dictate.toml with default config |
lint |
stalint |
Read-only | Reports violations without modifying files |
dictate |
kjr |
Destructive | Fixes whitespace, line endings, final newline |
watch |
- | Read-only | Monitors files and reports on change |
Point Dictator at a kernel-sized tree and every pre-existing violation lights up. That is correct, and useless on a pull request. Nobody reads 40,000 annotations about code they did not write.
--diff narrows enforcement to the lines the author actually touched:
dictator lint --diff origin/master # everything since the branch point
dictator lint --diff HEAD~3 crates/ # a range, restricted to a subtree
dictator lint --staged # what is staged, for a pre-commit hook
dictator lint --diff origin/master --diff-context 2 # widen each hunkFiltering is per line, not per file. Touching one line of a 6,000-line file reports that line and withholds the rest, with a count, because silently hiding debt is how a codebase rots:
Beastie is not happy with sys/kern/vfs_bio.c (1 violation)
sys/kern/vfs_bio.c:2841:9: 🔧 freebsd/style9/conditional-indent: ...
412 legacy violation(s) withheld (outside the diff)
--fix obeys the same boundary: only the lines in range are rewritten. Without
a diff selector, --fix still rewrites the whole file as before.
Whole-file rules still fire. A missing final newline, an over-long file, a
.Nm that disagrees with its filename: these describe the file, not a line, so
their span never lands inside a hunk. Each decree declares them via
file_scope_rules in its metadata (native and WASM alike, the field lives in
decree.wit), and they report whenever the file is touched.
Details that will bite you:
- The diff is taken between the base commit and your working tree, never
base..HEAD. Decrees parse the bytes on disk, so line numbers must describe those same bytes. Dirty trees and pre-commit hooks work correctly as a result. - Untracked files are entirely in scope under
--diff, becausegit diffomits them and a brand-new file is all the author's work..gitignoreis respected.--stagedignores them until yougit add, which is the point of--staged. --diff main...HEADand a bare--diff mainboth resolve throughmerge-base, so commits that landed on the base branch after you branched are not blamed on you.--diff a..bis taken at its left side, verbatim.- Shallow clones fail with instructions. In GitHub Actions set
fetch-depth: 0; locally rungit fetch --unshallow.
--format github emits workflow commands, which the runner renders as inline
annotations on the PR diff. Combined with --diff, they land only on lines the
pull request changed:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # --diff needs the merge base
- run: dictator lint --diff origin/${{ github.base_ref }} --format github .--format accepts human (default), json, or github, and works on lint
and watch alike.
Watch mode monitors file changes and validates instantly:
dictator watch .LLM workflow:
You: "Claude, generate user auth module"
Claude: *creates 15 files*
dictator stalint . → ❌ auth_helper.rb has trailing whitespace
dictator dictate . → 🔧 Fixed 3 files
dictator stalint . → ✓ All structural checks pass
RuboCop: *runs expensive quality checks*
Human workflow:
Save file → dictator stalint (50ms) → dictator dictate → Done
Dictator reports. You fix. Pre-commit hooks can auto-fix if you want automation.
.dictate.toml:
[decree.supreme]
# Universal structural rules (all files, all languages)
trailing_whitespace = "deny"
tabs_vs_spaces = "spaces"
tab_width = 2
final_newline = "require"
line_endings = "lf"
max_line_length = 120
# Ignore specific rules for specific files/extensions
# (Makefiles require tabs; Markdown code blocks may contain tabs)
[decree.supreme.ignore.tab-character]
filenames = ["Makefile", "GNUmakefile", "makefile"]
extensions = ["md", "mdx"]
[decree.ruby]
# Ruby-specific structural enforcement
max_lines = 300
[decree.golang]
# Go uses tabs, not spaces — overrides decree.supreme
tabs_vs_spaces = "tabs"
max_lines = 500
[decree.frontmatter]
# Frontmatter ordering (Markdown, Astro, etc.)
order = ["title", "slug", "pubDate", "tags"]
required = ["title", "slug"]Language overrides. Language decrees can override supreme settings. Go files use tabs even when supreme says spaces. The override applies per-file based on extension.
Rule ignores. Any decree can ignore specific rules for specific filenames/extensions via [decree.<name>.ignore.<rule>]. This is useful for cases like Makefile (tab-indented recipes) or documentation files that embed code blocks.
Decrees are WASM components. Each decree enforces structural boundaries for its domain. decree.supreme applies universally. Language decrees handle specific conventions.
decree.supreme (universal):
- Trailing whitespace detection
- Tab vs space enforcement
- Missing final newline
- Line ending consistency (LF/CRLF)
- Max line length
decree.ruby:
- File line count limits (ignoring comments/blank lines)
- Comment spacing (
#foo→# foo) - Tab detection (Ruby uses spaces)
- Blank line whitespace cleanup
decree.freebsd (persona: Beastie) — uncomment [decree.freebsd] in .dictate.toml to enable:
style(9)enforcement for kernel C code (.c/.h): brace placement, operator/keyword spacing, comment style, indentation, banned constructs- mdoc(7)/man-page enforcement (sections
.1-.9): macro ordering,.Nd/.Cd/.Dlquoting, SPDX header order, date format, module-load boilerplate - C99 preference (emaste@ suggestion): K&R definitions,
foo()instead offoo(void),register/auto,u_int32_t/quad_t, hand-rolledTRUE/FALSE,__inline, GNU named variadic macro parameters - Shared
banned-license-gplcheck across both file kinds
More decrees coming. Each language gets its own WASM decree for structural enforcement.
There is no roadmap. The Dictator does not make promises.
Contributions are welcome if accompanied by:
- Concrete benchmarks — prove your decree is fast
- Real usage — show it solves an actual problem you have
See decree.kjr (Kim Jong Rails) in DECREES.md for a complete example of building a custom decree using only the dictator-decree-abi crate.
The KJR decree demonstrates:
- Implementing the
Plugintrait - Emitting
Diagnosticviolations - Building as a WASM component
- Loading via
.dictate.tomlconfig
In Timeline 7, everything runs on KIMFS (Kim File System). Files cannot be structurally unsound — the filesystem itself rejects malformed structure at write time. Trailing whitespace? Denied. Wrong line endings? Denied. Methods in wrong order? Believe it or not, denied. Dictator is not a linter there, it's a fundamental law of physics.
In this timeline, files are not sentient and Dictator is a normal CLI binary. It only starts enforcing structure once it sees a configuration file:
.dictate.toml— not YAML, not XML. TOML is the contract.
Once configuration exists, Dictator has two operational modes:
- Watch and snitch —
dictator watchmonitors your files and reports structural violations as you edit. - Surprise inquisition —
dictator lintwalks your tree and reports every violation in a single pass.
AI coding assistants like Claude Code and OpenAI Codex can use Dictator not to “align” the LLM itself, but to align the files the LLM produces. Through MCP, they get two tools:
stalint(Static Lint). Despite the name, it doesn’t “lint” in the classic sense — it just reports structural violations: trailing whitespace, line endings, file size, etc. Read-only. No surprises.dictator. This one actually does things:- In
kimjongrailsmode, it fixes native structural errors (LF/CRLF, trailing spaces, missing final newlines, etc.). - In
supremecourtmode, it escalates to whatever linters you have configured — rubyfmt, Biome, Ruff, Clippy, Prettier, gofmt (or legacy RuboCop/ESLint if you must) — as defined in.dictate.toml.
- In
From the AI’s point of view, Dictator is the one calling the shots: external linters do the heavy lifting, Dictator orchestrates them, and then takes the credit.
Dictator includes an MCP (Model Context Protocol) server so these tools are discoverable and callable from compatible AI coding assistants.
Add to your Claude Code MCP configuration (~/.claude/settings.json):
{
"mcpServers": {
"dictator": {
"command": "/path/to/dictator"
}
}
}MCP mode is auto-detected when stdin is a pipe and no CLI arguments are provided.
| Tool | Description | Mode | Availability |
|---|---|---|---|
stalint |
Check files for structural violations (trailing whitespace, tabs/spaces, line endings, file size). Returns diagnostics without modifying files. Can check any path. | Read-only | Always |
dictator |
Auto-fix structural issues. Mode kimjongrails fixes whitespace/newlines. Mode supremecourt runs configured external linters from .dictate.toml. |
Destructive | Git repos only |
stalint_watch |
Watch paths for file changes. Runs stalint every 60s when changes detected. Restricted to cwd. | Read-only | Always |
Tool modes are dynamic:
kimjongrails: Always available (basic structural fixes)supremecourt: Only available if decrees have configured linters (e.g.,decree.ruby.linter.command = "rubyfmt")
From your AI assistant:
Check sandbox/ for structural violations
The assistant will call stalint and report violations with file, line, column, rule, and message.
To auto-fix:
Fix structural issues in sandbox/
The assistant will call dictator which fixes trailing whitespace, missing final newlines, and CRLF→LF conversions.
Using supremecourt mode:
If you have configured linters in .dictate.toml, the assistant can run them via supremecourt mode:
Fix structural issues in sandbox/ using supremecourt mode
The MCP server will:
- Detect file types in the provided paths (
.rb→ ruby,.ts→ typescript, etc.) - Check which decrees have configured linters
- Execute configured linters for detected file types
- Return combined output
Multi-layer protection against destructive operations:
- Git repository requirement:
dictatortool only exposed when.gitdirectory exists - Working directory boundary: Destructive tools (
dictator,stalint_watch) reject paths outside cwd - Sandbox mode support: Dictator advertises
codex/sandbox-statecapability and hides destructive tools in read-only mode - Dynamic mode detection:
supremecourtmode only available if external linters are installed
Examples:
- Run from
/tmp(no git) → LLM only seesstalint(read-only) - Run from project (has git) → LLM sees
dictatorbut it rejects/homeor/etc - No rubyfmt/biome →
supremecourtmode hidden from LLM
Note: As of 2025, some MCP clients don't send sandbox notifications. See Claude Code issues #3315, #3174, #3141 for related discussion.
The MCP server reads linter configurations from .dictate.toml:
[decree.ruby.linter]
command = "rubyfmt" # or "rubocop" for existing .rubocop.yml configs
[decree.typescript.linter]
command = "biome" # or "eslint" for existing ESLint configs
[decree.python.linter]
command = "ruff"
[decree.golang.linter]
command = "gofmt"Dictator controls the args. You only specify the command. Dictator adds the appropriate flags for auto-fix and JSON output parsing:
rubyfmt→-i(write-in-place formatter)rubocop→-A --format jsonbiome→lint --write --reporter jsoneslint→--fix --format jsonruff→check --fix --output-format jsongofmt→-w(lists changed files, then fixes)clippy→--fix --allow-dirty --message-format json
How it works:
- MCP server detects file types in provided paths
- Maps extensions to decree names (
.rb→ruby,.ts/.js→typescript,.py→python,.go→golang, etc.) - Executes configured linter with Dictator-controlled args
- Parses JSON output to unified diagnostics (🔧 fixed,
⚠️ warning, ❌ error) supremecourtmode only appears in tool list if at least one decree has a configured linter installed
Security: Linters run as subprocesses with provided file paths as arguments. User is responsible for ensuring configured commands are safe.
Pre-linter CI stage:
- name: Structural checks (fast)
run: dictator lint . # Fails fast if structure is wrong
- name: Formatting check (fast)
run: rubyfmt --check . # legacy projects may swap in: bundle exec rubocopPull request review (only what changed):
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: dictator lint --diff origin/${{ github.base_ref }} --format github .Pre-commit workflow:
# .pre-commit-config.yaml
- repo: local
hooks:
- id: dictator
name: Structural enforcement
entry: dictator lint
language: system
pass_filenames: trueLLM code generation guard:
AI generates code → dictator stalint → dictator dictate → Quality linters run
Monorepo boundary enforcement: One config, all languages. Ruby services, TS apps, YAML configs—same structural rules.
Development speed:
Instant feedback on saves. dictator stalint reports in milliseconds. dictator dictate fixes them.
MIT
Dictator: Snitches on your structure. Takes all the credit.