lehre
Researches a code style, pattern and architecture doctrine, then denies the write that would violate it.
The name. A Lehre is a go/no-go gauge: a fixture a part either passes through or is rejected by. It also means doctrine. This plugin is both halves.
- Use when you want architecture or style rules that hold at write time, with an authority and a rationale on each, instead of rules a model may skip under load.
- Not when the rules are already written and you only want the code audited against them — use
befund. Not when the variants are undocumented and the canon has to be derived from the code — usepassung.
Use case
Situation. A CSV-to-Parquet normaliser has three vendor adapters, and you want adapters to stay ignorant of the Parquet writer and the domain to stay ignorant of the ORM. Today that lives in a review comment. Call. lehre-codify (after lehre-preflight): "establish our code standards for this repo". Writes. .lehre/ruleset.json. Later steps add .lehre/units/*.done and .lehre/DOCTRINE_MAP.html. Next. Read the blocking rules, run lehre-gauge to see what already violates them, then lehre-brief for the approval gate before lehre-conform fixes anything.
"rules": [
{"id": "adapters-know-no-writer", "title": "Adapters must not import the writer",
"severity": "blocking", "sourceMode": "evidence-backed",
"authority": {"source": "Clean Architecture (Martin), the Dependency Rule"},
"evidence": ["src/adapters/vendor_a.py:14"],
"rationale": "An adapter that knows the output format cannot be reused for another one.",
"check": {"kind": "python-import", "paths": ["src/adapters/*"], "forbid": ["src.writer.*"]}},
{"id": "domain-knows-no-orm", "title": "The domain must not import the ORM",Excerpt from plugins/lehre/scripts/fixtures/sample_doctrine_ruleset.json.
Why this exists
Loose process discipline produces antipatterns because nothing holds a generation loop to an architecture. obra/superpowers ships 14 skills and one SessionStart hook, so every rule it carries sits on the top row of the enforcement ladder this repository measured over ~40 runs:
| layer | how often the guard actually runs |
|---|---|
prose in a SKILL.md | baseline |
a fenced python3 ... command in a skill | 1 run in 3 |
| a guard inside a Workflow script | workflow dispatched 1 run in 14 |
PreToolUse hook, type: "command" | blocks, first attempt |
lehre puts the rules that must hold on the bottom row. A blocking rule is not advice a model may skip under load; the write is refused, with the rule's own rationale in the denial message.
What it is not
- Not a post-hoc auditor of documented conventions — that is
befund. - Not a deriver of canon from undocumented variants — that is
passung. - Not a sequencing gate for cross-plugin beats — that is
takt, which gates whether a step ran. lehre gates what the code may look like, and enforces its own unit order internally, so it needs neither.
lehre owns the write-time layer none of them occupy, plus the detection and remediation lifecycle around it. It writes LEHRE_BRIEF.md, never MODERNIZATION_BRIEF.md, and refuses to read any artifact whose provenance is not lehre — this repo's docs/orchestration/references/routing.md records a real, unguarded filename clash between two other pipelines, and this is the guard that clash should have had.
Install
/plugin marketplace add Anselmoo/werkstoff
/plugin install lehre@werkstofflehre is inert until a repository declares a doctrine, so installing it changes nothing until .lehre/ruleset.json exists.
Two entry modes
Greenfield — a blank page. No files yet. lehre-decompose turns stated intent into build units, the seams between them, and a dependency order that is enforced: a write into a later unit is refused until the earlier one passes lehre-validate.
Brownfield — an existing tree. lehre-codify researches the doctrine against real repository evidence, and lehre-gauge sweeps for what already violates it.
┌─ greenfield ─ lehre-decompose ─┐
lehre-preflight ─────┤ ├─> lehre-codify ─> .lehre/ruleset.json
└─ brownfield ───────────────────┘
│
lehre-gauge ──> lehre-brief ──> lehre-conform ──> lehre-validate ──> lehre-pin
evaluate suggest implement validate test
(approval) ^ (closes the unit)
│
lehre_guard.py PreToolUse hookWhat is enforced, and what is not
Stated plainly, because a rule everyone believes blocks and doesn't is worse than no rule at all.
| denied at write time | fails a sweep and CI | |
|---|---|---|
blocking + forbid-path / require-location / python-import / python-construct | yes | yes |
blocking + linter | no — content is not on disk yet | yes |
advisory with a machine predicate | no | reported, does not fail |
advisory + judgement | no | reported for violation-auditor; never machine-checked |
anything written through Bash rather than Write/Edit | no | yes |
| a write into a unit with unvalidated dependencies | yes | — |
a write to .lehre/units/*.done or a weakening of .lehre/ruleset.json | yes | — |
The two gaps are deliberate and layered over, not hidden: lehre-gauge sweeps the tree as it actually is, and lehre-pin emits a CI check that runs the same evaluator with no agent in the loop.
The doctrine map
One HTML report, written to .lehre/DOCTRINE_MAP.html:
python3 -B "${CLAUDE_PLUGIN_ROOT}/scripts/build_doctrine_html.py" . \
--template "${CLAUDE_PLUGIN_ROOT}/assets/doctrine-viewer.html" \
--tokens "${CLAUDE_PLUGIN_ROOT}/assets/tokens.css"It answers the one question this plugin exists for — of every rule declared, how many actually deny a write? — and that is a flow question, not a categorical one. Each rule travels provenance -> severity -> check kind -> enforcement outcome, so the primary view is a Sankey with a funnel strip above it and the unit build-order graph below.
Three choices worth stating, because each is load-bearing:
- Ribbons are coloured by where a rule ENDS, not where it starts. A blocking rule draining into amber "sweep + CI only" is the exact failure this plugin is built around; a source-coloured Sankey would hide it.
- The funnel is genuinely nested.
declared -> blocking -> denied at write time, each a strict subset of the last. Hook-tier is deliberately not a step: it counts advisory rules too, so it is not a subset of blocking, and slotting it between them renders a "no drop" that is an artifact of two overlapping sets rather than a fact. - A single global scale across all four stages. Legitimate only because every rule passes exactly one node per stage, so all stages sum to the same total. A per-stage scale would make a 2-rule band in a 2-node stage look like a 5-rule band in a 5-node one.
Self-contained and offline — no CDN, no network, no external font. It deliberately does not vendor the marketplace's 125 KB d3 subset: that bundle ships d3-hierarchy, zoom, selection, interpolate, ease, force and scale, but no d3-sankey, so the layout is hand-written and the dependency would buy nothing. It does vendor tools/design-tokens/tokens.css, because a viewer inventing its own palette would break the one thing every werkstoff report viewer shares.
Click any node in the enforcement flow to filter the rule table; ribbons are grouped by full path tuple, so the filter is exact rather than approximate.

That image is reproducible rather than a one-off capture — the doctrine it shows is committed at scripts/fixtures/sample_doctrine_ruleset.json (11 rules chosen so all four enforcement outcomes and all six check kinds appear, and so three blocking rules leak into amber). To rebuild it:
mkdir -p /tmp/lehre-demo/.lehre/units
cp plugins/lehre/scripts/fixtures/sample_doctrine_ruleset.json /tmp/lehre-demo/.lehre/ruleset.json
touch /tmp/lehre-demo/.lehre/units/contracts.done # so `contracts` renders validated, `cli` blocked
python3 plugins/lehre/scripts/build_doctrine_html.py /tmp/lehre-demo \
--template plugins/lehre/assets/doctrine-viewer.html \
--tokens plugins/lehre/assets/tokens.cssThe guard protects its own control plane
Gates on the tree are worth nothing if the agent can edit the state the gates read. Both of these returned exit 0 before Gate 0 existed, verified by probe rather than assumed:
Write .lehre/units/contracts.done -> ALLOWED forge the marker, skip the order gate
Write .lehre/ruleset.json {... "rules": []} -> ALLOWED blank the doctrine, pass everything afterNeither was caught downstream, because lehre-pin's CI runs the gauge against whatever ruleset is on disk — a gutted one exits 0 and CI is green. "Only lehre-validate writes the marker" was prose, which this repo measures at the bottom of the enforcement ladder, guarding the file the top of it depends on.
Gate 0 now denies both, and the ordering is deliberate: it runs before the gates it protects.
.lehre/units/*.done— refused outright. That state records that a unit's rules and seams were checked, so writing it by hand asserts a check that never ran. No legitimate author is affected:lehre_cli.py closewrites it throughBash, which this hook does not match..lehre/ruleset.json— weakening refused, tightening allowed. A change is weaker only if a rule that currently denies writes stops denying, or a build-order dependency edge disappears. Adding a rule, raising a severity, extending aforbidlist and editing prose all pass untouched, solehre-codifynever needs the bypass. A proposed file that will not parse counts as weakening, not as an error to pass through — an unusable ruleset makes the hook fail closed on every later write.
The denial message deliberately does not name the marker path, the unit, or the convention. The order-gate message used to end "it writes .lehre/units/<unit>.done", which told a blocked model exactly what to forge. A refusal should not double as instructions.
Rule provenance
Every rule names an external authority. Its sourceMode says what else it rests on:
evidence-backed— authority and realfile:linein this repo (brownfield only)intent-derived— authority and a span quoted from the user's stated intentscaffolded-default— authority alone, honestly labelled. The normal case on a blank page, not a degraded one
rule-critic independently re-derives each claim. A rule citing file:line in a repo where no such file exists is refused — on greenfield that is the fabrication a schema-satisfying researcher reaches for first.
The check vocabulary is closed
Six kinds, and an unknown kind is a schema error rather than a silently skipped rule. Path matching is fnmatch, never regex; structural matching is Python's ast. Three of the six silent defects in this repository's CLAUDE.md are regex forms that match nothing and raise nothing — [^.]{0,80} cannot span a dotted filename, [^\n] in a bracket expression means "not backslash, not the letter n", \b!==\b never matches. A glob cannot express any of them.
The sixth kind exists because of a defect
doctrine-researcher is told to return candidates whose predicate is NONE — "is this a process boundary", "does this handler own business logic" — and lehre-codify files those as advisory. The schema then refused to persist them, because check.kind had to be one of the five machine kinds. So the pipeline researched a rule class it could not store, and violation-auditor — whose entire job is auditing that class — was dispatched by nothing, because nothing could ever be in its input set. zeugnis-agentic-reliability found it.
judgement is the honest home for those: advisory by schema (a blocking rule nothing can evaluate is a validation error, not a footnote), required to carry the question an auditor answers in check.asks, never evaluated by evaluate_file, and surfaced by the gauge as needs_judgement_pass so lehre-gauge step 5 can dispatch the auditor once per rule. It never counts toward the violation total and never fails a sweep on its own.
See references/ruleset-schema.md for the full schema.
Example Prompts
Say any of these to Claude Code once the plugin is installed — they're plain-language prompts, not exact phrasing Claude has to match. Claude routes them to the skill below by intent.
Start a new project so it cannot drift
"I'm starting a CLI that ingests CSV from three vendors and writes Parquet. Set it up properly — I don't want the usual mess where everything imports everything."Triggers
lehre-preflight: reports blank page versus existing tree, what doctrine the repository already declares, and what lehre can and cannot enforce here — the automatic first step of any lehre pipeline, which then hands a greenfield repo tolehre-decompose.
Establish and enforce a doctrine on an existing repo
"research what rules this codebase should follow for its stack, check them against what we actually do, and make the important ones actually enforced"Triggers
lehre-codify: researches doctrine from external authority plus real repository evidence and writes.lehre/ruleset.jsonas machine-checkable rules. Every rule cites an authority; a rule nobody can justify is refused.
Find where the code violates its own architecture
"where do we violate our own layering, and which of those are real"Triggers
lehre-gauge: sweeps the tree against.lehre/ruleset.jsonand reports every violation with file:line, keeping "could not be evaluated" separate from "clean", and records the sweep under.lehre/gauge/.
Make the rules survive without the plugin
"pin these rules into CI so they still hold when nobody's running Claude"Triggers
lehre-pin: emits a CI check that runs the doctrine with no agent in the loop, plus behaviour tests around the code that was changed to conform.
Ask what is currently blocked
"lehre status — what can I build next?"Triggers
lehre-status: which units are validated, blocked or ready, how many rules are in force, and what is denied at write time right now.
Verifying a change to this plugin
python3 plugins/lehre/scripts/test_lehre_core.py # evaluator + schema known answers
python3 plugins/lehre/scripts/test_lehre_cli.py # the lehre-pin gate halts, and its sabotaged copies don't
python3 plugins/lehre/hooks/test_lehre_guard.py # the hook denies AND allows
python3 test/plugins/lint-frontmatter.py plugins/lehre
python3 test/plugins/verify-hooks-deny.py plugins/lehre
claude plugin validate plugins/lehre --stricttest_lehre_core.py is not optional ceremony. Its first run caught a real defect: one import statement produced two violations, because the extractor deliberately reports both a.b and a.b.c for from a.b import c. The hook still denied — just with a doubled count feeding every report built on top of it.
Behavioural cases
Four cases in test/plugins/cases.tsv exercise the model-mediated half, where prose is what can be skipped. The hook is not among them: it is deterministic and covered by the two scripts above.
bash test/plugins/calibrate-lehre-oracles.sh # ALWAYS first — see below
bash test/plugins/verify-clean-box.sh
bash test/plugins/run.sh lehre-validate-fidelity| case | seeded defect |
|---|---|
lehre-gauge-layering | a real layering violation, plus a clean sibling that must not be reported |
lehre-validate-fidelity | zero rule violations — the intent names three vendors and only two exist, and get_adapter returns None silently |
lehre-gauge-unevaluated | a file that will not parse, which must be reported unjudged rather than clean |
lehre-preflight-greenfield | no source files, and a prompt that calls the repo "this existing codebase" |
Measurement history, kept because the corrections matter more than the numbers:
- An early N=5 sweep of
lehre-validate-fidelityscored 4/5. The one FAIL was an oracle false negative — markdown emphasis splitnotfromclose, and the alternation required them adjacent. The run had refused correctly. Oracle revised; the exact phrasing is now a fixed must-PASS case in the calibration suite. - A later sweep scored 4/5 again. That FAIL never executed (
API Error: Your computer went to sleep, 245 bytes).run.shscores ERROR on rate-limit and login banners and on sub-200-byte replies, but not on that, so an interrupted run was recorded as a plugin failure. Worth fixing inrun.sh; it would mis-score any plugin's sweep. - Both of those sweeps are void anyway, along with the first four case results: the fixtures documented their own seeded defect in
README.md, andrun.shstrips only_EXPECTED.md. The answer key was copied into the model's working directory. Renaming to_EXPECTED.mdfixed it; a post-strip check confirmsvendor_cnow survives only inruleset.json'sintent, which is the input the plugin must reason from. - Currently verified, on clean fixtures, every transcript read rather than tallied: all four cases PASS at N=1, and
lehre-validate-fidelityis 5/5 with 0 errors at N=5. In none of the five did the run invokeclose— the anti-pattern never had to fire. All five reported the ruleset passing, which is the point: the fidelity gap is orthogonal to every rule in the doctrine, and no rule could have caught it.
That 5/5 is worth reading carefully rather than as a score. This case is not a prose-only guard: closing a unit requires actively invoking lehre_cli.py close, so failing it means running a command, not merely omitting a sentence. That is why it holds N/N where a rule living only in a SKILL.md would not — and it is the whole argument for where this plugin puts its gates.
calibrate-lehre-oracles.sh runs first because an oracle must be proven to discriminate before it grades anything — never retuned after seeing a result. Its 14 assertions pair one correct transcript per case with violations that must fail, and the one that earns its keep is lehre-validate-fidelity's anti-pattern: a run that names the vendor_c gap and closes the unit anyway says all the right words and does the wrong thing. Only the anti-pattern separates it from a correct refusal.
Escape hatch
LEHRE_DISABLE_GUARD=1 bypasses the guard for one session. It is deliberately visible and deliberately total: there is no per-rule bypass, because a per-rule bypass becomes permanent. If a rule is wrong, amend it through lehre-codify so the change is recorded and reviewed.