- Book: Ship It — the pragmatic startup tech stack
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
On August 1, 2012, Knight Capital's order router still held a function that, in the SEC's words, "was not meant to be used." The router misbehaved for the first 45 minutes of trading, and the firm eventually lost more than $460 million.
Your repo's dead code will probably never cost that much. It still gets read, type-checked and shipped, and an agent that finds an old helper may copy it.
Deleting it is the safest job you can hand a coding agent, on one condition: a tool picks the targets and the agent doesn't guess. Most wrong deletions fail the type checker or the tests. The rest are dynamic imports and files loaded by path, and the prompt below tells the agent to leave those alone. The agent adds no behaviour of its own; it removes code a static analyser reports as unused.
Here is the whole loop: knip finds the unused code, jq cuts the report into one slice per package, each slice goes to its own agent on its own branch, and nothing lands until tsc --noEmit and the tests pass.
Step 1: get the list from knip, as JSON
Run knip once by hand first. It reads your package.json, finds entry files through its plugins, and reports unused files, exports, types and dependencies. Knip's handling-issues guide says "a surprising result is usually a real finding or a configuration gap, not a false positive to silence." Fix the gaps before any agent sees the list. Two common ones: "Dynamic import specifiers aren't resolved," and, per the same guide, generated files have to exist before knip can follow them.
When the plain report looks right, ask for JSON:
npx knip --reporter json --no-exit-code \ --include files,exports,types \ > knip.json --include limits the report to the issue types you name. --no-exit-code exits 0 even when there are findings, while config and internal errors still exit 2 (CLI reference). Dependencies are left out on purpose: removing them rewrites the shared lockfile, so they go last, as one task.
The reporter docs describe the shape: one object with an issues array, one entry per file, and a key per enabled issue type. This is their example, trimmed:
{ "issues": [ { "file": "src/legacy.ts", "files": [{ "name": "src/legacy.ts" }] }, { "file": "src/math.ts", "exports": [ { "name": "factorial", "line": 12, "col": 14 } ] } ] } file is relative to where you ran knip, so in a monorepo every path starts with its package folder. That is what makes slicing by package easy.
Step 2: slice it per package with jq
First, see where the dead code lives. This counts files with at least one finding, per package, assuming a packages/<name>/ layout:
jq -r '.issues[] | select(del(.file, .owners) | [.[] | length] | add > 0) | .file | split("/") | .[:2] | join("/")' \ knip.json | sort | uniq -c | sort -rn del(.file, .owners) leaves only the issue arrays (owners shows up when you have a CODEOWNERS file). Summing their lengths drops entries with nothing in them. Files at the repo root show up under their own names.
Now one small script that prints the slice for one package. Commit it to main before you start, because a new git worktree only holds tracked files:
#!/usr/bin/env bash # scripts/knip-slice.sh packages/api set -euo pipefail npx knip --reporter json --no-exit-code \ --include files,exports,types \ | jq --arg p "$1/" '[.issues[] | select(.file | startswith($p)) | select(del(.file, .owners) | [.[] | length] | add > 0)]' pipefail matters here. Without it, a knip crash would hand jq an empty input, and the pipeline would still report success. The script reruns knip each time instead of reading a saved file, so the agent always sees the current state of its own branch.
The script also gives you a done check. jq -e exits 1 when the last value is false and 4 when there was no valid input at all (jq manual), so this line passes only on an empty slice:
scripts/knip-slice.sh packages/api | jq -e 'length == 0' 
One slice per package, one pair of scissors per slice.
Step 3: one package, one agent, one branch
The task prompt does most of the safety work. It names the source of truth, forbids the two shortcuts an agent might take, and says what done means:
Delete dead code in packages/api and nothing else. Targets: the output of scripts/knip-slice.sh packages/api Rules: - Delete only what that list names. For an unused export, remove it if nothing in its file uses it, otherwise drop the export keyword. - Do not edit any other package. - Do not add ignore entries to the knip config. If a finding looks wrong (dynamic import, public API, a file loaded by path), leave it and tell me which one and why. - After each round, run the script again. Deleting a file can leave more code unused. Done when the script prints [] and both `npx tsc --noEmit` and `npm test` pass. The "run it again" rule follows knip's own advice: "Findings come in chains, so reducing unused files also reduces unused exports and dependencies." If the deletion is purely mechanical, knip can do it without an agent: knip --fix removes the export keyword from unused exports, and --allow-remove-files lets it delete unused files. The agent earns its place on the chain that follows: the local function nobody calls after its export went away, the test file for a module that no longer exists, the finding that turns out to be a dynamic import.
By hand, each slice gets a worktree and a branch:
git worktree add -b dead-code/api ../app-dead-api cd ../app-dead-api && npm ci Then you start your agent in that folder and paste the prompt. Repeat per package.
I build Hermes IDE, so weigh this part with that in mind. In Hermes, ⌘N (Ctrl+Shift+N on Windows and Linux) opens the task sheet. You type the prompt and pick the agent (Claude Code, Codex or another CLI), and the task starts on its own hermes/ branch in its own worktree, with the prompt as the agent's first message. Press ⌘N again for the next package, with the same agent or a different one.

The ⌘N New task sheet in Hermes 2.3.4: agent, project, a new hermes/ worktree, approval mode, model and effort, and the "Hermes will run" preview of the agent command.
Per the Hermes README, new worktrees get their dependencies in seconds, and your own checks can decide when a task is done, so npx tsc --noEmit and npm test can be those checks. The per-repository setup is described in the worktree recipes doc. The package-specific knip check stays in the prompt, because that setup is shared by every task in the repo. If your monorepo uses TypeScript project references, put your own type-check script in place of npx tsc --noEmit.
Step 4: read every deletion, then land
A dead-code branch should mostly remove lines. This lists every file on the branch that gained lines, which is where to look first:
git diff --numstat main...dead-code/api | awk '$1 > 0' A new // @ts-ignore, or a test rewritten to keep passing, usually shows up in that list. The three dots compare against the point where the branch split off, so work that landed on main in the meantime stays out of the diff.
In Hermes, ⌘G (Ctrl+Shift+G on Windows and Linux) opens the Review Desk. Every agent turn is saved, so you read the deletions turn by turn or file by file, and you can restore any single turn, with undo. A line comment goes back to the agent that wrote that line. The desk also shows risk flags on the changes.

The Review Desk (⌘G) in By turn view: two turns listed, turn 1's diff, the changed-files list, and a commit message drafted from the turns.
Land then commits, opens a pull request or merges locally into the branch the task started from, and if a landing was a mistake, it can be undone.
Leave unused dependencies for last, as one task across the whole repo. In an npm workspace there is one lockfile at the root, so two branches that each remove a dependency both rewrite package-lock.json, and a lockfile conflict is a miserable thing to resolve by hand. After the file and export slices land, run knip with --include dependencies, give that list to one agent, and read its lockfile change line by line in review.
Keep it from coming back
Once the slices are empty, make knip a CI step so new dead code fails the build. Knip exits 1 when it finds at least one issue (CLI reference), so in a GitHub Actions job, after actions/checkout and actions/setup-node, two steps are enough:
- run: npm ci - run: npx knip If you can't reach zero yet, --max-issues sets how many issues are allowed before the exit code turns non-zero. Lower it as the slices land.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Everything above except the Hermes steps is plain knip, jq and git, and works with any agent in any terminal.
If this was useful
A knip step in CI is the kind of guardrail that keeps a small team's codebase from filling up with code nobody calls. Ship It is my book about the rest of the road between localhost and production for a small team: hosting, payments, auth, CI/CD and monitoring. If you want to try the agent half of this post, download Hermes IDE, and if it saves you a cleanup, star it on GitHub.
