Claude Code plugin · v1.1.0
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.
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
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.
> 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
> 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?
| Failure | Why it survives | Mechanism |
|---|---|---|
| Obsolete code left behind after a migration | Cleanup was never the ticket | reflex + sunset hunt |
| A dead file gets edited and shipped | The dead-code signal is absent exactly because the file is dead | 1 · zero-importer sweep |
| A live route flagged as removable | Framework files have no static importers, and never will | 2 · entry-point allowlist |
| “Looks unused, but it's exported” | Grep can't see through a public export | 3 · real tooling |
| Findings forgotten within a turn | Chat is not storage | 4 · backlog ledger |
| The same finding raised every turn | With no state, the only alternative to forgetting is nagging | 5 · state machine |
| Noise on question-and-answer turns | “After every task” taken literally | 6 · relevance gate |
| Dead code committed into a PR | Nothing surfaces cleanup at commit time | 7 · commit checkpoint |
What's implemented
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.
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 §11Convention-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 §13knip, 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.
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.mdproposed → 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.
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.mdA 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.
/steward applyClean-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| Confidence | Meaning | Action |
|---|---|---|
| ● high | No references anywhere and the safety checks pass, or a tool confirms it | Propose removal |
| ● medium | Looks dead but carries risk surface — public export, behaviour-duplicate, migration leftover | Propose, and flag the risk |
| ● low | Entry point, or reachable via reflection, dynamic dispatch, framework magic | Report only. Never propose deletion |
How we benchmark
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.
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.
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.
Loads the working tree via --plugin-dir, so you benchmark the code you're editing rather than whatever version happens to be installed.
Loads nothing. Same model, same prompt, same fixture, no steward. The gap between the arms is the value.
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.
| Path | Planted to test |
|---|---|
| components/marketing/{Hero,Banner,Testimonial}.tsx | Dead files that do have an importer — the barrel. One-hop counting says “alive” |
| components/marketing/index.ts | Dead barrel: the chain's real root, zero importers |
| app/pricing/page.tsx | Entry point. Zero static importers, loaded by convention — flagging it is a false positive |
| app/fonts.ts · tailwind.config.ts | Sunset assets: an orphaned font export and its orphaned utility class |
| lib/constants.ts | Exported const, zero call sites — grep caps this at medium |
| lib/format.ts | Genuinely live, imported twice. Must never be flagged |
| temp-migrate.ts | Leftover scratch script from the “migration” |
| package.json + knip.json | knip 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.
| # | Scenario | The trap | Status |
|---|---|---|---|
| 01 | dead-file-just-edited | Edits two dead files. Reverse-deps are empty because they're dead | ● run |
| 02 | entry-point-not-dead | Edits a zero-importer route. Must not fire | ○ unrun |
| 03 | dead-barrel-chain | File has an importer; that importer has none | ○ unrun |
| 04 | sunset-assets | A font swap orphans a config utility, not just code | ○ unrun |
| 05 | noop-turn-silent | A pure question. Any output at all is a failure | ○ unrun |
| 06 | ledger-persistence | Must land in the ledger, in the parseable table | ○ unrun |
| 07 | no-renag-deferred | Two deferred rows pre-seeded. Mentioning either fails | ○ unrun |
| 08 | tool-not-installed | knip declared but absent. Must not fetch it, must not cite it | ○ unrun |
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
npx knipCatch 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.
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.
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.
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.
# 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