A lint gate for agent context: one rule line binds a file pattern to a registered checker, and the same line is what the agent reads when it opens the file.
ACDSL (Agentic Context Domain Specific Linter) is a wrapper around deterministic checkers. A rule names the files it governs and the program that checks them; acdsl check runs the program, the Read hook delivers the rule's text to the agent, and a verdict log counts how often each rule went red while its text was in context. That log decides which prose stays and which rule becomes gate-only. The language itself is a dozen fields on one line. The full paper, with the experiments behind the design, is acdsl-spec.pdf.
One line per rule in acdsl/rules.acdsl (task contracts live in their plan directories). The marker is the file class's comment prefix plus acdsl:; a .md or .html file closes it with -->.
//acdsl:ACDSL-GOLANG-FMT-001 gofmt anchor="\.go$" anchor-not="(/testdata/|^vendor/)" why="Every Go file is gofmt-formatted — run gofmt -w before committing"
| Field | Required | Meaning |
|---|---|---|
| ID | yes | KIND-SCOPE[-TOPIC]-NNN; scope and topic must be registered in context/aspects.json (gate-enforced by ACDSL-SMINE-003). Verdict statistics key on it, so renaming a rule restarts its statistics. |
| VERIFIER | yes | A name in the registry. Only registered programs run: rules are data, verifiers are code. |
| anchor | yes | RE2 over repo-relative paths; where the rule is declared carries no meaning. An anchor matching no file is a violation (the typo guard), except for task contracts, where the missing file is the finding. |
| anchor-not | RE2 exclusion applied after anchor. | |
| why | yes | The sentence the agent reads and every failure cites. Cite the context entry it enforces in parentheses: that citation is the coverage metric on the Context tab. |
| lifetime | doctrine (default, gated on every run) or task: a plan's promised artifact, red until it exists, gated with -lifetime task, retired by renaming the file away from .acdsl. | |
| reach | smine (default: this repo), global, none (disabled, fixtures still run), or a list of target repo names. | |
| projected | false makes the rule gate-only: the gate runs, the text reaches no prompt. See The ablation loop. | |
| empty, needs | empty="ok" tolerates a zero-match anchor (archived plans); needs="p1,p2" skips the rule while a file the verifier reads is absent. | |
| key="value" | Anything else is passed to the verifier as a parameter. |
A malformed field, a missing anchor or why, a duplicate ID or an unregistered verifier is reported as a violation (exit 1), through the same channel as a rule breach.
acdsl/registry.json maps a name to an invocation; a target repo overlays any name through acdsl/registry.local.json without touching the rules. argv must be non-empty, timeout_s within 1..300.
"gofmt": {
"argv": ["go", "run", "./cmd/acdsl/verifiers/gofmtwrap"],
"timeout_s": 30,
"description": "fails when an anchored file is not gofmt-formatted"
}
Any executable that honors the contract can stand behind a name. The engine calls <argv...> <files-list-path> [key=value ...] under the entry's timeout; baseline verifiers shell out only through internal/shell.Run to allowlisted programs.
| Exit | Meaning |
|---|---|
| 0 | Pass. |
| 1 | Violations, one per stdout line as file:line: message. Exit 1 without such a line still surfaces as a diagnostic. |
| ≥ 2 | Tool error; the whole check run aborts. |
acdsl check builds the git file universe once (git ls-files --cached --others --exclude-standard, so untracked files the agent just wrote are seen), resolves every anchor, runs each verifier, prints one line per violation (message: [RULE-ID] verifier — why, or JSON with -json) and appends a verdict record. It also refuses staged projection blocks and blocks committed at HEAD.
| Where it runs | How |
|---|---|
| make audit | Builds bin/acdsl and bin/verifiers/*, strips projection blocks, regenerates, then runs check (verdicts cached under bin/acdsl-cache/, scope per registry entry) and fixtures beside vet, the skill checks and the Go tests. |
| Agent read time | The acdslhook PostToolUse hook: see What the agent sees. It never blocks a read. |
| Agent write time | The tool-policy gate guards the rule surface per the mode. |
| Commit | The pre-commit hook runs acdsl guard, the staged-projection scan alone. |
| CI | The audit job runs make audit; the test job's make test-acdsl-smoke (cmd/acdsl/smoke_test.go) asserts the rule and fixture counts with ACDSL_VERDICTS_ENABLED=0. |
Fixtures. Every rule carries pass and fail sets under acdsl/testdata/<rule-id>/; acdsl fixtures proves the verifier against them. A rule without a failing fixture is unproven, and eviction needs green fixtures.
In a Claude session the acdslhook PostToolUse(Read) hook injects the governing rules beside the Read result, as - [ID] why lines under ACDSL rules governing <path>:, on every read. The file on disk is never touched. It acts only when the session cwd holds acdsl/registry.json; park its hook group on the Hooks tab or type #disable inject:acdsl to switch it off.
For consumers that read bytes, acdsl project -file <path> writes the same lines into the file as a comment block in the file class's own syntax (never into JSON, .cue, .js, .bru, .acdsl or binaries). Blocks are working-copy only: make audit, the routine runner and the pre-commit hook strip or refuse them, and acdsl check refuses a committed one. Rules whose own consumers would break on a block carry projected="false" (ACDSL-SMINE-001, ACDSL-GOLANG-DOCS-001, ACDSL-SMINE-004 to -010).
acdsl project -plan <path> answers which rules would govern a path that does not exist yet; design plans record their planned files' rules through it.
Every check run appends one JSONL record to $ACDSL_VERDICTS_PATH, else ~/.claude/acdsl/verdicts.jsonl (home-anchored, since worktrees die with their sessions). Zero-violation rows are the denominator; the branch is the session proxy. Logging is best-effort and never changes the exit code; ACDSL_VERDICTS_ENABLED=0 disables it.
{"ts": "2026-08-07T12:00:00Z", "root": "/abs/worktree", "branch": "claude/slug", "outcome": "clean",
"rules": [{"id": "ACDSL-GOLANG-EXEC-001", "projected": true, "violations": 0}],
"diagnostics": [{"id": "ACDSL-GOLANG-EXEC-001", "message": "file.go:12: ..."}]}
Statistics are keyed on (rule, projected): a rule flipped mid-window shows two rows, and that pair is the A/B comparison. check and fixtures never read the flag; enforcement is the constant, delivery the variable, and the prompt side carries no "also enforced" hint that would contaminate the arm.
acdsl verdicts -since 720h
| Decision | Criterion |
|---|---|
| Evict to gate-only | Verifier and fixtures green, and ≥ 300 logged runs on the projected arm with zero reds. Any red keeps the projection: ten 1 % failures are ten failed features, so a low red rate is never an argument. |
| Re-project | Any red on the gate-only arm flips the rule back. |
| Cadence | A monthly verdicts -since 720h review. The Context tab flags both candidate classes and never flips; a flip is one field edit plus a commit. |
The bar is a calibration, not a constant; the rule set churns, so the arms are never stationary. What the loop reliably gives is visibility: a heavily violated rule is unmissable. The end state is context that shrinks toward judgment rules and facts while everything mechanical lives as a gate.
The nightly smine-acdsl stage mines rules sessions keep breaking into change tasks; /fimplement lands each as one commit. Every task declares its route, in priority order; the lower routes are last resorts.
The check fits a tool the repo already gates with (golangci-lint via .golangci.yml, shellcheck, a JSON schema): a config edit, plus a rules.acdsl line on the existing wrapper verifier when the rule is new. The tool's config is the configuration surface, never a custom reimplementation.
A registered verifier covers it with other params or anchors: one rules.acdsl line (next free ID in its family) plus fixtures. No new code.
Only when the task says why routes 1 and 2 fail: the run generates the verifier, the registry entry, the rule and the fixtures, then proves them (acdsl fixtures, check -rule <id>, make audit). Bounds in acdsl/evalgen.json, enforced by ACDSL-GOLANG-GEN-001: Go only, a line cap, imports from the stdlib, this module or go.mod's require set (generation never adds a dependency), an exec allowlist, a timeout cap, bands F and A only, and autoApply: false so a nightly run never generates one unattended.
| Gate | Job |
|---|---|
| golangci-lint | Generic Go hygiene (errcheck, gosec, staticcheck, …) and the [lint]-tagged slices of house rules. make lint is the differential CI gate: only lines the branch changed. |
| ACDSL | House doctrine no ecosystem linter encodes: the Component.Method: error prefix, the test schema, new(expr) initialization, exec through internal/shell. Whole-file, per rule, logged. |
The seam is the tag doctrine of context/rules/go.md: a rule is [lint] with a Covered by <linter> bullet, or [prose]; a partly automatable rule is split in two. The gate ACDSL-GOLANG-DOCS-001 (linttags) reads .golangci.yml as text and goes red when a Covered by claim names a linter the config does not enable, so the config needs an explicit enable list. Where golangci-lint stands behind an ACDSL rule (ACDSL-GOLANG-LINT-001, registry entry golangci), the wrapper groups files by directory, reads JSON from a file, lifts the issue caps, drops (typecheck) findings and keeps findings on anchored files. Several linters fight the house style and stay off: ST1003 and revive's initialism check, ST1005, fieldalignment, gochecknoinits, gocritic/elseif.
acdsl/policy.json says who may change the rule system. acdsl check enforces it (compiled into bin/acdsl) by diffing the working tree against merge-base(HEAD, base), the base found from origin/HEAD → origin/main → main → master; the tool-policy gate refuses the writes at once. An agent branch cannot self-escalate; an unresolvable base in strict or gated mode is a hard error.
| Mode | Editable |
|---|---|
| strict | Nothing: no rule, registry, evalgen or policy change, no unsanctioned overlay entry. |
| gated | Rules in the scopes of editable_scopes, binding verifiers from verifier_allowlist; the policy, registry, evalgen, bin/acdsl and every verifier path stay frozen. The default shipped to targets. |
| free | Everything (an absent file). What smine itself runs. |
Task contracts stay usable in every mode while allow_task_contracts is true. New verifier binaries never depend on the mode: they go through the evalgen flow. For client-level strictness a target can also deny Edit(acdsl/**), Write(acdsl/**), Edit(**/*.acdsl) and Write(**/*.acdsl) in its .claude/settings.json.
acdsl dist -target <name> -dest <dir> ships a target's slice: the reach-covered rules as verbatim lines under a synced-from-smine header, the registry subset they name with argv rewritten to bin/verifiers/<name>, the prebuilt binaries and policy.json (mode from the source's dist_mode). The Context page's sync calls it in process when "sync acdsl" is on. A rule ships only when the target has files its anchor matches; skipped rules are reported and picked up by a later sync. Targets own their own *.acdsl files and registry.local.json; in a strict or gated target only entries listed in the policy's local_overrides are accepted. The deployed read hook activates by itself and logs to the shared home verdict sink.
The config server's Context tab shows the rule cards with their delivery badge and per-arm red rates, the eviction and re-projection candidates, the coverage derived from why citations, a run-fixtures button and the recent gate runs. reach is editable there; delivery is not. Scopes are the shared taxonomy of context/aspects.json: a rule's scope is its ID's second segment, an unregistered one shows amber, and a member cited by any rule cannot be deleted.
Source cmd/acdsl/main.go. Exit codes: 0 clean, 1 violations, 2 tool error. Environment: ACDSL_VERDICTS_PATH, ACDSL_VERDICTS_ENABLED=0.
| Command | Does |
|---|---|
| acdsl check | Resolve anchors, run verifiers, print violations, log verdicts. -json, -lifetime doctrine|task|all, -rule <id> (one rule, the dry run for a new verifier), -registry <file> (overlay by name), -report <file>, -root <dir>. |
| acdsl fixtures | Prove each rule's verifier against its pass and fail sets. |
| acdsl guard | The staged-projection scan alone (the pre-commit hook). |
| acdsl project -context <path> | The delivered rules of a governed file (the read hook's data source). |
| acdsl project -file <path> | Insert, refresh or remove the on-disk block for byte readers. |
| acdsl project -strip | Remove every block (the pre-commit sweep). |
| acdsl project -plan <path> | The rules that would govern a path that need not exist yet. |
| acdsl verdicts | Aggregate the log per (rule, projected): runs, reds, violations, last red. -path, -since <duration>. |
| acdsl dist | Ship a target's slice. -target, -dest, -root, -task. |
| File | Holds |
|---|---|
| acdsl/rules.acdsl | The doctrine rules, one line each; its header documents the local conventions. |
| acdsl/registry.json | Verifier name → {argv, timeout_s, description}; registry.local.json overlays by name in a target. |
| acdsl/testdata/<rule-id>/ | Pass and fail fixtures. |
| acdsl/policy.json, acdsl/evalgen.json | The mode; the generation bounds. |
| ~/.claude/acdsl/verdicts.jsonl | The verdict log. |