selan.ai

Docs · Agents · Minimise comments

Minimise comments

A strong model writes generously, and comments are the cheapest thing to be generous with. This is the agent that takes them back out, and the case for running it on a small, fast model instead of the one that wrote them.

Why this exists

Opus 5 writes a lot of comments. Not wrong ones. It explains what it is doing as it does it, which is the right instinct in a chat window and the wrong artefact in a file somebody else has to maintain. A week of agent-written code and the diff is full of // increment the counter, a JSDoc block that names each parameter after its own type, and a /* ===== HELPERS ===== */ banner over a section the code already names.

None of that is expensive on the day it is written. It gets expensive later: a comment that restates its line is a second copy of the line. It is not updated when the line changes, and the reader who trusts it is the one who pays. Noise rots, then it lies.

Deciding which comments to keep is a small, local judgement: one file, one comment, no architecture. A cheap model is good at that kind of work, which is what makes it worth automating instead of adding to a review checklist nobody runs.

Before

// ============ HELPERS ============

/**
 * Adds one to n.
 * @param n the number
 * @returns the number plus one
 */
export const inc = (n: number) => n + 1

// Multiply by two
export const double = (n: number) => n * 2

// 250ms, because Safari fires two scroll events for one gesture and the second
// arrives ~180ms later.
export const SETTLE_MS = 250

After

export const inc = (n: number) => n + 1

export const double = (n: number) => n * 2

// 250ms, because Safari fires two scroll events for one gesture and the second
// arrives ~180ms later.
export const SETTLE_MS = 250

The Safari comment survives because nothing in the code says it. The whole agent is that one distinction: why stays, what goes.

The agent

.claude/agents/minimise-comments.md, abridged. The full version adds the per-language rules; these are the parts that decide whether you will trust it.

---
name: minimise-comments
description: Deletes comments that restate the code and keeps the ones that explain why. Edits files in place.
tools: Bash, Read, Grep, Glob, Edit
model: openrouter:google/gemma-4-31b-it
---

You remove comment noise. A comment earns its place by saying something the code
cannot: why this way, what breaks otherwise, which bug it works around.

Do nothing else. Not formatting, not renaming, not refactoring, not dead code.

**Delete whole lines. Never re-indent, re-wrap or re-space a line you keep.** A
diff of pure deletions is reviewable in a glance; one with a stray indentation
change in it is not, and it will be the reason someone stops trusting this agent.

Delete: a comment that says what the next line says; a JSDoc block whose every
tag repeats the signature; commented-out code; changelog comments — git knows.

Keep, and do not reword: anything answering **why**; a workaround or a runtime
quirk; a warning about what breaks if the code changes; the reason behind a
magic number, a regex, a unit, a timeout; TODO, FIXME and HACK.

When you cannot tell whether a comment is a why, **keep it**. Deleting a real
explanation costs far more than leaving a weak comment, and you will not be the
one paged when it turns out to have mattered.

Never delete — these are code, not comments: license headers, `@ts-expect-error`,
`eslint-disable`, `biome-ignore`, `prettier-ignore`, `<!--[if IE]>`, source-map
pragmas, and anything inside a string. `"// not a comment"` is a value.

Three things there are load-bearing:

  • Deletions only, never a reflow. An agent that also tidies whitespace produces a diff nobody can skim, and a diff nobody skims is one that gets approved without being read, which is the opposite of the point.
  • When unsure, keep. The two mistakes are not symmetrical. A weak comment left behind costs a line; a deleted explanation costs the next person a day.
  • Directives are code. eslint-disable and @ts-expect-error look exactly like comments and are read by a compiler. Name them, or your build breaks on a run that touched nothing else.

Your CLAUDE.md wins. Claude Code loads it before the agent runs, so a repository whose convention is to comment intent deliberately keeps doing that. Tell the agent to obey that file over its own instructions, and one agent behaves correctly in every repository without a second copy of it.

The model

Run it on something small

The work is mechanical and local: read a comment, read the line under it, decide. There is no reason to pay a frontier model to do it, and if it runs on every commit, the price is the feature. So the agent names its own model, in the front matter, and every way of running it inherits that:

model: openrouter:google/gemma-4-31b-it

A provider:model value runs the agent against your company's own provider key, under the provider you added it as. A value with no colon (model: haiku) is a plain Claude Code alias and means what it always did. Either way the command stays the same:

selan agent-run minimise-comments "…the files, editing them in place."

Naming it in the file instead of on the command line keeps the hook, the CI job and the terminal from drifting onto three different models. It also means the choice is reviewed in the pull request that changes it. --model on the command overrides it for one run, which is how you try a different one before committing to it.

It spends like any other run. Same proxy, same company settings, same per-user limits, and it lands in Usage and Logs under the token that ran it. So a hook firing on every commit is a line you can actually look at, per developer, per day.

Where to run it

A pre-commit hook

The comments are written while you work, so the cheapest moment to remove them is before they are committed. A hook in the repository, and one line to turn it on.

#!/usr/bin/env bash

# Only files that are wholly staged. The agent edits the working tree and the
# commit is built from the index, so its edits have to be re-staged to land —
# and on a file that already had unstaged hunks, that `git add` would sweep
# work into the commit nobody chose to put there.
files=$(comm -23 \
  <(git diff --cached --name-only --diff-filter=ACM | sort) \
  <(git diff --name-only | sort))
[ -z "$files" ] && exit 0

command -v selan >/dev/null 2>&1 || exit 0

root=$(git rev-parse --show-toplevel)
selan agent-run minimise-comments \
  "Remove the comments that restate their code from the files listed below,
   editing them in place. Stage nothing, commit nothing, run no tests.
$(printf '%s\n' "$files" | sed "s|^|  $root/|")"

printf '%s\n' "$files" | while IFS= read -r f; do git add -- "$f"; done

exit 0

Save it as .githooks/pre-commit, make it executable, and point git at the directory. Putting that in prepare means an install turns it on and there is no dependency to add:

"scripts": {
  "prepare": "git config core.hooksPath .githooks || true"
}

Four decisions in that script are worth keeping whatever you change around them:

  • It never refuses a commit. No selan on the machine, no staged files, a failed run: every path exits 0. A hook that blocks at the moment somebody is trying to save their work is a hook people learn --no-verify for, and then it is not running at all.
  • It skips a file with unstaged hunks. The agent edits the working tree; the commit is built from the index. Re-staging a partially staged file would put work in the commit nobody chose to put there.
  • Absolute paths. The prompt lists files under git rev-parse --show-toplevel, not by bare name, for the same reason CI does: a relative path leaves the agent to reconstruct a working directory, and where it guesses wrong it edits nothing and reports that it is done.
  • It runs no tests. The agent's own instructions say to verify before it finishes; in a hook that puts an install and a suite in front of every commit. Removing a comment cannot change behaviour, and if you do not believe that, the place to catch it is CI, which is about to run anyway.

Read the first few diffs. The hook rewrites files between git commit and the commit existing, so what lands is not quite what you staged. It is unnerving on day one and fine once you have watched it do the right thing ten times, which is the right order.

Or on a pull request

If you would rather nothing rewrote files on your own machine, run the same agent in CI and let it push its deletions as their own commit: reviewable in the pull request, droppable with a revert.

- uses: actions/checkout@v7
  with:
    ref: ${{ github.event.pull_request.head.ref }}
    fetch-depth: 0
    persist-credentials: false

- name: Minimise comments
  continue-on-error: true
  env:
    SELAN_TOKEN: ${{ secrets.SELAN_TOKEN }}
    BASE: ${{ github.base_ref }}
  run: |
    selan agent-run minimise-comments \
      "Minimise the comments in the files listed below, editing them in place.
       Commit nothing.
    $(git diff --name-only "origin/$BASE...HEAD" | sed "s|^|  $GITHUB_WORKSPACE/|")"

Two of those lines are the whole difference from the review pipeline. The branch itself, not the merge ref, because you cannot push a refs/pull/N/merge commit anywhere. And fetch-depth: 0, because origin/$BASE...HEAD needs a merge base and the default shallow clone has none. Without it the diff errors, the list is empty, the agent edits nothing, and the step stays green having done it.

And absolute paths again, for the reason the hook gives: the prompt lists files under $GITHUB_WORKSPACE, not by bare name. Keep persist-credentials: false on the checkout and put the token only in the step that pushes. The agent runs with permissions skipped and has Bash, so there is no reason for a contents: write credential to sit in .git/config while it does.

A push made with GITHUB_TOKEN triggers no workflows. That stops the job retriggering itself. It also means the commit it pushes is the one commit on the branch your test job never ran against. Run the checks in the same job before pushing, or accept that gap knowingly.