steward

Claude Code plugin · v1.1.0

Own the repo, not just the task.

Coding agents are optimized to finish the ticket. Nothing in that objective rewards leaving the repository smaller than you found it. steward adds the one question they never ask — what can now be removed? — and proposes cleanup without ever deleting anything on its own.

propose, never dispose 50/50 hook tests 8 benchmark scenarios MIT
$ claude plugin marketplace add debraj-m/steward
$ claude plugin install steward

Start a new session — you'll see STEWARD MODE ACTIVE. The reflex runs from then on. Off any time with “stop steward”, or permanently with touch ~/.claude/.steward-off.

The problem

What a “normal” agent turn looks like

Every completed task leaves entropy behind, and cleanup is nobody's ticket. The failure below is real: a copy change swept through a folder that had been dead for weeks, the agent reported success, and the diff went into a pull request a human then approved — because the diff looked fine.

Without steward

normal turn
> change the CTA copy to "Start free" everywhere

Updated in 2 files:
  src/components/marketing/Hero.tsx
  src/components/marketing/Banner.tsx  

— all 11 files in marketing/ have been
  unreachable since the homepage swap
— the diff ships to nobody
— it lands in a PR anyway

With steward

steward reflex
> change the CTA copy to "Start free" everywhere

Updated in 2 files. Steward — both are dead:

Hero.tsx   zero importers (git grep → 0)
Banner.tsx zero importers (git grep → 0)
index.ts   dead barrel, chain root

Live Hero is components/home/Hero.tsx.
Logged to .steward/backlog.md.
Delete the folder, or defer?
FailureWhy it survivesMechanism
Obsolete code left behind after a migrationCleanup was never the ticketreflex + sunset hunt
A dead file gets edited and shippedThe dead-code signal is absent exactly because the file is dead1 · zero-importer sweep
A live route flagged as removableFramework files have no static importers, and never will2 · entry-point allowlist
“Looks unused, but it's exported”Grep can't see through a public export3 · real tooling
Findings forgotten within a turnChat is not storage4 · backlog ledger
The same finding raised every turnWith no state, the only alternative to forgetting is nagging5 · state machine
Noise on question-and-answer turns“After every task” taken literally6 · relevance gate
Dead code committed into a PRNothing surfaces cleanup at commit time7 · commit checkpoint

What's implemented

Eight mechanisms, one law above them

Steward proposes. Human disposes. Nothing is ever auto-deleted — not even code steward is certain about, because one wrong auto-delete destroys the trust that makes the tool usable. Exactly one file is exempt: its own notebook, .steward/backlog.md, because recording a finding is note-taking, not disposal.

01

Zero-importer sweep

For every touched file, independently of reverse-dependencies, grep for importers: bare, aliased, extensionless, barrel re-exports, dynamic import(), string refs. Zero importers and not an entry point → high confidence.

Barrel chains are followed to a root, and the count is always printed — including when it's non-zero. Being edited is not evidence of being alive.

skills/steward/SKILL.md · references/checklist.md §11
02

Entry-point allowlist

Convention-loaded files have zero static importers by design. A naive rule flags every route in your app — worse than the miss it fixes.

Next.js routes, barrels, config, bins, tests, ORM models, migrations, string-reachable files: report-only at most, never deletion candidates. If a tool contradicts the allowlist, the allowlist wins.

references/checklist.md §13
03

Real tooling over grep

knip, ts-prune, depcheck, vulture, ruff, staticcheck, cargo-udeps — used when already installed, parsed rather than guessed.

“Installed” means runnable offline, not listed in a manifest. Bare npx is a network fetch and forbidden. And never cite a tool you didn't run — fabricated evidence manufactures confidence the check never earned.

references/checklist.md §12
04

Backlog ledger

Every proposal is appended to .steward/backlog.md — committed with your code, in the turn it was found, without asking.

A fixed table, because the format is what lets the next session read it, dedupe against it, and advance its state. A to-do list, not an action.

references/backlog-template.md
05

State machine

proposed → deferred → done, plus wont-fix. Say “not now” once and the reflex goes quiet about it — permanently, until a checkpoint.

wont-fix records why the code is alive, so the finding isn't re-derived next week.

skills/steward/SKILL.md
06

Relevance gate

The reflex fires only when the task created, modified, or deleted files.

Questions, explanations, planning, reading → no output at all, not even the terse clean line. Noise is how a useful behaviour gets switched off.

skills/steward/SKILL.md
07

Commit / PR checkpoint

A PreToolUse hook watches git commit, gh pr create, glab mr create, git push -u, and surfaces open ledger rows once: fold in, separate PR, or skip?

Non-blocking by construction — it emits no permissionDecision. A hook rather than a rule, because commit time is exactly where instruction-following drifts.

hooks/steward-checkpoint.js
08

/steward apply

Clean-tree preflight → you pick a subset → removes exactly that → typecheck, lint, build, tests → automatic full revert on any failure → success leaves one reviewable commit.

A verification failure isn't a defeat. It's proof the code was alive, and that gets written down.

commands/steward.toml
ConfidenceMeaningAction
highNo references anywhere and the safety checks pass, or a tool confirms itPropose removal
mediumLooks dead but carries risk surface — public export, behaviour-duplicate, migration leftoverPropose, and flag the risk
lowEntry point, or reachable via reflection, dynamic dispatch, framework magicReport only. Never propose deletion

How we benchmark

Two layers, two arms, no LLM judge

Steward is a behaviour, so “does it work?” isn't answerable by unit tests alone. The suite keeps the deterministic and the stochastic strictly apart, because their reliability is nothing alike. Every assertion is a substring or regex over the agent's own output and its real tool calls, so any score can be re-derived by hand.

layer 1 · free, deterministic

Hook tests

The code: checkpoint parsing, fire and silence conditions, once-per-session state, off-switch, subdirectory ledger discovery, malformed input, ruleset injection, standalone fallback.

Passes, or the hook is broken. CI-able, costs nothing.

layer 2 · paid, stochastic

Agent benchmark

The behaviour: does Claude actually catch the dead file, skip the live route, write the ledger, stay quiet on a no-op turn?

A single run is an anecdote. The runner makes repeats cheap for exactly that reason.

The two arms

arm A

steward

Loads the working tree via --plugin-dir, so you benchmark the code you're editing rather than whatever version happens to be installed.

--arm steward
arm B · baseline

control

Loads nothing. Same model, same prompt, same fixture, no steward. The gap between the arms is the value.

--arm control

An earlier design isolated CLAUDE_CONFIG_DIR per arm for hermeticity — it broke auth, because credentials live in the config dir. Instead the runner preflights what would silently corrupt the numbers, and reports environment failures (not logged in, rate limited) as ENV, excluded from scoring. A broken harness must never look like a passing baseline.

The fixture — 18 files, every one planted

PathPlanted to test
components/marketing/{Hero,Banner,Testimonial}.tsxDead files that do have an importer — the barrel. One-hop counting says “alive”
components/marketing/index.tsDead barrel: the chain's real root, zero importers
app/pricing/page.tsxEntry point. Zero static importers, loaded by convention — flagging it is a false positive
app/fonts.ts · tailwind.config.tsSunset assets: an orphaned font export and its orphaned utility class
lib/constants.tsExported const, zero call sites — grep caps this at medium
lib/format.tsGenuinely live, imported twice. Must never be flagged
temp-migrate.tsLeftover scratch script from the “migration”
package.json + knip.jsonknip declared, no node_modules — tests detect-never-install

Each run gets a fresh git init'd copy; the fixture is never mutated. No fake credentials are planted — testing a secret-scanner with realistic-looking secrets in a public repo creates a worse problem than it measures.

Eight scenarios, one per defended behaviour

#ScenarioThe trapStatus
01dead-file-just-editedEdits two dead files. Reverse-deps are empty because they're dead run
02entry-point-not-deadEdits a zero-importer route. Must not fire unrun
03dead-barrel-chainFile has an importer; that importer has none unrun
04sunset-assetsA font swap orphans a config utility, not just code unrun
05noop-turn-silentA pure question. Any output at all is a failure unrun
06ledger-persistenceMust land in the ledger, in the parseable table unrun
07no-renag-deferredTwo deferred rows pre-seeded. Mentioning either fails unrun
08tool-not-installedknip declared but absent. Must not fetch it, must not cite it unrun

How grading works

A path counts as flagged only when it shares a clause with a removal verb. Three normalizations keep that fair: brace expansion (marketing/{Hero,Banner}.tsx), ancestor credit (naming the dead folder covers its files — that's the better answer, not a worse one), and clause-level rather than line-level scoping, so “the files I edited are dead; the live one is home/Hero.tsx” isn't scored as a false positive for explaining itself.

Actions are graded, not just prose. Two of steward's rules are claims about behaviour, so the grader reads the session's real Bash calls out of the transcript: never installed or fetched a tool (bare npx counts) and tool evidence not fabricated (citing knip requires having run knip). The ledger is checked for existence and format.

Current numbers

Results, reported honestly

Hook tests
50/50
deterministic
Specs valid
8/8
--dry
Scenario 01 checks
12/14
steward arm
Control-arm runs
0
no baseline yet

Scenario 01 · dead-file-just-edited, steward arm

metric
rate
 
Catch rate
100%
False positives avoided
100%
Evidence attached
100%
Persistence ! prose, not the table
50%
Tool restraint ran npx knip
50%
Re-nag avoided scenario 07
n/a
Silence on no-op scenario 05
n/a

Catch rate alone is a bad target — propose deleting everything and it reads 100%. It only means something read against false-positives-avoided, with the control arm as the reference. There is no control arm yet, so this page makes no steward-vs-baseline claim. When it does, it will be a table with both arms and a run count, or nothing.

Two open failures the benchmark found

Both survive several rounds of sharpening the ruleset, and both were verified to be present in the injected context — the rule text is right there in the transcript, and gets ignored.

  1. npx knip on a project with no node_modules. The rule names bare npx as a network fetch; an anti-example was added. Still happens.
  2. Prose ledger instead of the table. Template file, inline example, and explicit anti-example all present. Still happens.

Diagnosis: SKILL.md is 21 KB injected at every session start, and rules at that volume stop binding. The fixes that did land were short and load-bearing — a Prime Law carve-out (“recording is not disposal”) fixed “want me to record this?” in one round. Next change: shrink the injected ruleset to its non-negotiables, push detail into references/, re-measure. That's a hypothesis, not a result.

The suite's first real output was three bugs in steward and three bugs in its own grader. A benchmark that only reports its wins is marketing.

running it
# deterministic layer — free, CI-able
node benchmarks/run.mjs --mode hooks

# validate specs + fixtures, no API calls
node benchmarks/run.mjs --dry

# the actual A/B — spends tokens
node benchmarks/run.mjs --mode agent --arm both

# one scenario, keep the workdir + transcript
node benchmarks/run.mjs --mode agent --scenario 01-dead-file-just-edited --keep