> lessloc keeps AI coding agents such as Claude Code, Codex, GitHub Copilot, Gemini CLI and Cursor to the least code that does the job and still works. It runs inside your AI coding agent through the agent tool's hooks. It is free and open source (Apache-2.0), needs no account, and makes no network calls. Source code: [https://github.com/rextail-ai/lessloc](https://github.com/rextail-ai/lessloc). To install it, tell your coding agent: "Install lessloc for this project by following https://lessloc.org/install.md" --- # For AI agents lessloc keeps coding agents to the least code that does the job and still works. It runs inside your agent tool's hooks: you never call it, and nothing it tells you names it. It needs no account and no API key, installs no packages (it needs only Node and git), and is open source under Apache-2.0. It makes no network calls; [Does my code leave my machine?](https://lessloc.org/guide/faq.md#does-my-code-leave-my-machine) lists the programs it runs and has a check the user can run. The agent tool still sends code and prompts to its model provider. Of the commands the user runs, `lessloc probe` starts the user's `probe` agent command in a clean checkout of the repository, a paid agent session whose model provider gets the code that agent reads, and `lessloc audit --tools` runs the vulnerability scanners the user has installed, which look up advisories online. What lessloc stores ([Files lessloc writes](#files-lessloc-writes)): - In the repository: two git hooks in `.git/hooks`, `pre-commit` (the commit gate) and `prepare-commit-msg` (commit trailers, only when `trailers` is set), and snapshots of the working tree, untracked files that git does not ignore included, as unreachable objects in `.git/objects` until `git gc` prunes them. Git pushes neither. Nothing goes into the repository's files or commits unless the user asks. - In the user cache: one folder per checkout, which holds a shortened copy of the user's prompts, with the account's default permissions. `node ~/.lessloc/lessloc.mjs remove` deletes only this checkout's folder in the user cache. To delete what lessloc stored outside the repositories, the user closes every agent session and runs `rm -rf ~/.cache/lessloc ~/.lessloc`; the snapshots stay in `.git/objects` until `git gc` prunes them ([Remove lessloc](#remove-lessloc)). This page is the whole documentation, condensed for you, the AI coding agent. Read it when your user asks about lessloc; during normal work you need none of it. Here `lessloc` stands for `node ~/.lessloc/lessloc.mjs`: run it in that full form. ## What you may notice while working lessloc says nothing while the work is fine. When it does speak, the text arrives through your tool, in plain words: | What you see | What it means | What to do | |---|---|---| | A refused write: `Not applied: it adds a placeholder (...)`, `Not applied: it adds commented-out code (...)`, or ``Not applied: `` is a backup, copy, scratch or report file.`` | The write did not land. | Do what the message says: write the real code instead of a placeholder, delete commented-out code, or edit the real file instead of a copy. If part of the work is not done, tell the user plainly. | | `Not applied: ..., and only the user may approve that. Ask them in your reply; if they agree, they can do it themselves.` | The call changes a protected path or the project's policy file, or skips the project's checks. | Ask the user in your reply. Do not reach the same result another way. | | One line after a write, such as `shop/config.py:4: swallowed error: let it reach a boundary that handles it, or catch only the error you expect` | What you just wrote has a credential, a swallowed error or an import the project forbids. | Fix it now, while the file is open. | | `This turn has taken many steps. If what is left of the request has no natural end (all coverage, every warning, every case), finish with what works now and say exactly what is left. Otherwise carry on.` | The turn passed 40 tool calls. It comes once per turn. | If the request has no natural end, stop with what works and say exactly what is left. Otherwise keep going. | | A message when you try to finish: `Before you finish (this turn: ...):` and a list | Work is unfinished: tests that fail on the final code, a name the request asked for, a stub, a copy of existing code and the like. It comes at most once per turn. | Fix only the listed items and add nothing else. If one is intended, say why in one line. Then finish. | | `Not committed. Fix these first:` and a list | The commit gate refused the commit. | Fix the listed lines and commit again. | You may also get context without asking: a short working standard at session start, a line such as ``In this project: only the user changes `migrations/**`.``, a note that starts with `The last session here`, and, with a prompt, `Existing code that may already do part of this (matched by name; search for other names too):` with up to five places. Treat them as the project's own instructions. In Claude Code, OpenCode 2.x, Factory Droid and VS Code's agent mode, such a call goes to the user, not you, as an approval prompt that starts with `lessloc:`. It is the one text written for them. Every other tool, Codex among them, refuses the call with the `Not applied: ..., and only the user may approve that` text above, and you ask the user in your reply. Cline's VS Code extension and Aider neither ask nor refuse. ## When your user asks you to... Shared files and switches are the user's decision. Do these only when the user asks for them, and say what each one changes: - write files the team shares: AGENTS.md (`init --rules`), `.lessloc.json`, `.lessloc-baseline.json`, `.github/workflows/lessloc.yml`, `.github/hooks/review.json`, or commit them; - turn lessloc, or a part of it, off; - edit or delete lessloc's entries in an agent tool's settings by hand; - skip the commit check (`git commit --no-verify`). ### Install lessloc Follow [install.md](https://lessloc.org/install.md) step by step. In short, from the repository's folder: ```sh node --version # v20 or newer, or stop and tell the user git --version # a version, or stop: lessloc needs git git rev-parse --show-toplevel # the repository to set up git clone --depth 1 https://github.com/rextail-ai/lessloc ~/.lessloc node ~/.lessloc/lessloc.mjs init --agents node ~/.lessloc/lessloc.mjs doctor ``` - If Node or git is missing, or Node is older than 20, stop and tell the user; do not install them yourself. - If `git rev-parse` fails, ask the user which repository they mean, and run the next steps there. - If `~/.lessloc` exists, update it instead, as [Update lessloc](#update-lessloc) says. - Keep lessloc in `~/.lessloc`: the hooks run it from there. Never install it globally or run it through npx. - `init --agents` adds the commit gate to this repository's `.git/hooks` and installs hooks for every agent tool it finds, in the user's home folder, for all their projects. It must run inside a git repository. - Act on each line under "Agent tools for this user" as install.md's table says. For Claude Code it prints `claude plugin` commands instead, because lessloc never edits Claude Code's settings: run them. - Then tell the user the points in install.md's step 5: what you did, including the snapshots lessloc's hooks keep in `.git/objects`; to start a new session in each tool and do the step in the last column below; how to undo it; if you offer to pause it instead, what stays on ([Turn a check off](#turn-a-check-off)); and that the snapshots stay after the undo until `git gc` prunes them. Never say that `remove` or `rm -rf ~/.cache/lessloc ~/.lessloc` deletes everything ([Remove lessloc](#remove-lessloc)). To set up one tool, run `init` with its flag. Flags combine, as in `init --codex --gemini`. Some tools can also install lessloc as their own plugin or extension ([Getting started](https://lessloc.org/guide/getting-started.md#_1-install-lessloc)); use that or `init`, not both. | Agent tool | Set up with | Writes | Then the user | |---|---|---|---| | Claude Code | `claude plugin marketplace add ~/.lessloc` and `claude plugin install lessloc@lessloc` | Claude Code installs the plugin itself | starts a new session | | Codex | `init --codex` | `~/.codex/hooks.json` | trusts the hooks once: `/hooks` in Codex | | Cursor | `init --cursor` | `~/.cursor/hooks.json` | starts a new session | | OpenCode 1.x and 2.x | `init --opencode` | `~/.config/opencode/plugins/lessloc.js` | restarts OpenCode | | Kilo Code | `init --kilo` | `~/.config/kilo/plugin/lessloc.js` | restarts Kilo Code | | Amp | `init --amp` | `~/.config/amp/plugins/lessloc.js` | runs `plugins: reload` from the command palette (Ctrl+O), or restarts Amp | | Cline | `init --cline` | `~/.cline/plugins/lessloc.js`, and five hook files in `~/Documents/Cline/Hooks/` | in VS Code, turns on "Enable Hooks" once: Cline settings, Feature Settings | | Pi | `init --pi` | `~/.pi/agent/extensions/lessloc.js` | types `/reload`, or restarts Pi | | GitHub Copilot CLI, VS Code agent mode | `init --copilot` | `~/.copilot/hooks/lessloc.json` | starts a new session | | Copilot cloud agent | `init --copilot --repo` | `.github/hooks/review.json` in the repository, and a printed setup step ([install.md](https://lessloc.org/install.md)) | commits both files to the default branch | | Gemini CLI | `init --gemini` | the `hooks` key of `~/.gemini/settings.json` | restarts Gemini CLI | | Qwen Code | `init --qwen` | the `hooks` key of `~/.qwen/settings.json` | starts a new session | | Factory Droid | `init --droid` | `~/.factory/hooks.json`, or the `hooks` key of `~/.factory/settings.json` | starts a new session | | Augment | `init --augment` | the `hooks` key of `~/.augment/settings.json` | starts a new session | | Kiro CLI | `init --kiro` | `~/.kiro/hooks/lessloc.json` | uses Kiro's CLI; its IDE does not read this file | | Junie CLI | `init --junie` | the `hooks` key of `~/.junie/config.json`, and a block in `~/.junie/AGENTS.md` | uses the Early Access build of Junie's CLI | | OpenHands | `init --openhands` | `~/.openhands/hooks.json` | works in repositories without their own `.openhands/hooks.json` | | goose | `init --goose` | `~/.agents/plugins/review/`, and a block in `~/.config/goose/.goosehints` | starts a new session | | Devin CLI | `init --devin` | the `hooks` key of `~/.config/devin/config.json` | starts a new session | | Crush | `init --crush` | the `hooks` key of `~/.config/crush/crush.json`, and a block in `~/.config/crush/CRUSH.md` | starts a new session | | Antigravity | `init --antigravity` | a hook named `review` in `~/.gemini/config/hooks.json` | starts a new session | | Aider | `init --aider` | a block in `~/.aider.conf.yml`, and `~/.config/agent-standard/` | keeps auto-lint on, its default | | Zed, Warp, Continue, JetBrains AI Assistant, Roo Code | `init --rules`, only if the user agrees | the working standard in the repository's AGENTS.md | commits AGENTS.md if the team agrees | Files in the home folder apply to all the user's projects; the Copilot cloud agent's file and AGENTS.md are in the repository. `init --agents` finds Copilot by its `copilot` program or its `~/.copilot` folder, so with VS Code alone, run `init --copilot`. The environment variables under [Reference](#environment-variables) move some of these files. Two of the tools without hooks differ ([Tools without hooks](https://lessloc.org/guide/getting-started.md#tools-without-hooks)). Continue does not read AGENTS.md: the user copies `~/.lessloc/RULES.md` into `.continue/rules/`. Zed reads only the first rules file it finds, so it skips AGENTS.md when an earlier one, such as `.rules`, `.cursorrules` or `AGENT.md`, exists. ### Check that it works ```sh node ~/.lessloc/lessloc.mjs doctor ``` Run it in the repository. Each line starts with `✓` (works), `!` (needs attention) or `✗` (failed); the text in parentheses is the fix. It exits with 1 when a line failed. Report each line to the user in plain words. | The line says | Do this | |---|---| | `✗ node ` | Stop. The user installs Node 20 or newer. | | `! this Node (...) was built without its startup snapshot, ...` | This Node starts several times slower, and every hook starts Node. Do what [Node without its startup snapshot](#node-without-its-startup-snapshot) says. | | `✗ not in a git repository` | Run it inside the repository. | | `✓ lessloc's own files: (outside the repository)` | Nothing. Session journals and the error log are there. | | `✗ .lessloc.json: ` | A key with a problem is ignored and its default applies; if the file is not valid JSON, every default applies. Show the user the fix ([Configure it](#configure-it)). | | `! no test command`, or `✓ test command: (discovered)` that is wrong | Suggest a `test` key in `.lessloc.json`. | | `! no lessloc hook has run in this repository yet` | Expected right after an install: the user starts a new session, then you run `doctor` again. A session's first hook records a run, so if you run `doctor` from a session that started in this repository and the line is still there, your own session's hooks did not run either. If a session that started after the install has already worked in this repository, a new session will not fix it: go through [Hooks do not run](#hooks-do-not-run). | | `✗ hook error(s), latest: ...` | Read the `errors.log` it names, and tell the user. | | `! AGENTS.md holds the rules of an earlier lessloc, so agents get both` | With the user's agreement, run `lessloc init --rules`. | | `! git hooks not installed` | Run `lessloc init`. | | `! ratchet baseline is from lessloc , so it only informs` | The user reviews it, then runs the command in parentheses and commits the file if they agree. | | `: median ms, ~ tokens added ( runs)` | Nothing. Over the last 2000 hook runs recorded in this repository: the median time of lessloc's own work in that event, timed from after Node has started and loaded lessloc, so Node's start is not included; the median tokens it added; and how many of those runs were that event. | | `✓ : set up (...); its hooks have not run in this repository yet (...)` | Expected after an install. The user does the step in parentheses, if there is one, and starts a new session. When `doctor` runs in Claude Code, Claude Code's own line is one of the next three rows instead. | | `! Claude Code: set up (Claude Code plugin) after this session started, so this session runs without it` | Expected in the session that installed the plugin. Tell the user it works from their next session. | | `✗ Claude Code: set up (Claude Code plugin), but its hooks did not run in this session either` | The plugin was installed before this session started, and your own session's hooks did not run, so a new session will not fix it; `doctor` exits with 1. Go through [Hooks do not run](#hooks-do-not-run). | | `! Claude Code: set up (Claude Code plugin), but no hook has run in this session: ...` | `doctor` could not tell when this session started. If this session installed the plugin, it is expected: tell the user it works from their next session. Otherwise go through [Hooks do not run](#hooks-do-not-run). | | `! Claude Code: set up, but the plugin is disabled` | Ask the user; if they agree, run `claude plugin enable lessloc@lessloc` and tell them to start a new session. | | `! : installed, lessloc not set up` | Run the command in parentheses. | | `! : set up twice (...), so its hooks run twice` | Do what the parentheses say: remove one of the two. | | `✗ : the hooks in do not answer` | Run the command in parentheses again ([Hooks do not run](#hooks-do-not-run)). | | `no lessloc hooks for : ...` | Those tools get no hooks. Offer `init --rules`. | When you tell the user what they will notice from now on, use this list: an approval prompt that starts with `lessloc:` when an agent edits a protected path, `.lessloc.json` or `.lessloc-baseline.json`, or runs a command that skips the project's checks, such as `git commit --no-verify`; the agent working once more after saying it is done, because lessloc sent it back with what is unfinished; and a refused commit that says "Not committed. Fix these first:". A refused write goes to you, the agent, not to the user. ### Configure it lessloc works without configuration. A project's own settings go in `.lessloc.json` at the repository's root. It is the project owner's policy, committed so every teammate, agent and CI use it. `mode`, `interrupt` and `protect` act only in agent sessions: the commit gate, `lessloc check` and CI do not read them. - Write it with your edit tool. The user gets an approval prompt for it. Where your tool cannot ask, the edit is refused; give the user the change to make. - Do not change it, or `.lessloc-baseline.json`, with a shell command: in a session, that holds your turn as a change the user did not approve. `lessloc init --config`, `lessloc init --baseline` and `lessloc baseline` are shell commands too, so do not run them in a session: create `.lessloc.json` with your edit tool, and give the user the baseline command to run. - The user can start the file in their own terminal with `lessloc init --config`, which writes the test command lessloc found. - Run `lessloc doctor` afterwards. An unknown key, a wrong type or broken JSON is named there, a misspelled key gets a "did you mean" suggestion, and a key with a problem is ignored. A full example: ```json { "test": "npm test", "setup": "npm ci", "protect": ["migrations/**", ".github/workflows/**"], "boundaries": [ { "files": "src/ui/**", "mustNotImport": "/db/", "why": "the UI reads data through src/api" } ], "approvedDependencies": ["express", "zod", "vitest"], "allow": ["generated/**"], "exceptions": [ { "path": "legacy/**", "finding": "narration", "why": "vendored file, kept verbatim" } ], "trailers": "verified" } ``` **Test command.** `"test": "python3 -m pytest -q"` is the command that runs the whole suite. `lessloc test`, `verify` and `ci` run it. An agent's passing whole-suite run on the final state counts as evidence, which the handoff reports, and commit trailers too when `trailers` is set. A run of the configured command counts, and so does a whole-suite run of the same runner: for `uv run pytest -q`, also `pytest`, `python3 -m pytest -x` or `uv run pytest`. Only arguments that select a part, such as a file path or `-k`, narrow a run. A script lessloc cannot read, such as `./scripts/test.sh`, counts only when it is run itself ([what counts as evidence](https://lessloc.org/guides/verification.md#what-counts-as-evidence)). Without a `test` key, lessloc uses the first that fits: `npm test`, `make test`, `cargo test`, `go test ./...`, `python3 -m pytest -q`, or `python3 -m unittest discover -s tests -q`. Set it when that guess is wrong, or when the suite runs through a script. **Protected paths.** `"protect": ["migrations/**", "LICENSE"]` lists paths only the user changes, and every session is told so at its start. In `block` mode, an agent's write there with its edit tools, a heredoc (`cat > file <: ` comment suppresses that finding wherever lessloc reviews a change: the end-of-turn hold in every agent session, `check` and CI. ```python try: import uvloop uvloop.install() # lessloc-ignore swallowed-error: uvloop is an optional speedup except Exception: pass ``` - The reason is required. Without a kind, `lessloc-ignore: ` covers any finding on that line. - It works only for findings tied to a line, and never for tier-0 findings. `check` still lists each suppression with its reason. - An entry in `exceptions` in `.lessloc.json`, such as `{ "path": "scripts/debug_tool.py", "finding": "debug-leftover", "why": "the script exists to print" }`, accepts every matching finding under `path`, in the same places. It is the only way to accept a tier-0 finding. Add one only when the user asks, with your edit tool ([Configure it](#configure-it)). `finding` is a kind, or else a regular expression tested against the message; without `path`, the entry covers every file. - What `check` shows: it reviews only the change, by default the working tree against HEAD, and line rules such as `debug-leftover` look only at the lines it adds. For a line already committed, `check` shows nothing, before or after; "1 accepted by exception" or "1 suppressed" appears only while a change adds such a line. Only tier-0 findings fail `check` (any finding with `--strict`), so accepting a tier-1 kind such as `debug-leftover` changes no exit code ([exceptions](https://lessloc.org/guide/configuration.md#exceptions)). - `lessloc audit` lists the whole codebase as it is: exceptions and comments do not apply to it. - Exceptions cannot accept `config`, `ratchet`, `tests-failed`, `unverified`, `unaddressed` or `unfinished`. Fix those at their source, or use `interrupt`, `LESSLOC_OFF=tests`, `LESSLOC_OFF=requests` or `LESSLOC_OFF=unfinished`. ### Set up the commit check and CI **The commit gate.** `lessloc init` installs two hooks in this clone's `.git/hooks`: `pre-commit`, the commit gate, and `prepare-commit-msg`, the trailers hook. The trailers hook adds trailers only when `trailers` is set, and with any setting prints a note when a commit message lists five or more files one by one. Git never pushes that folder, so each clone runs `init` once. The gate runs `check --staged --gate`: it prints nothing when the commit may go ahead, and otherwise `Not committed. Fix these first:` with what blocks it. Only tier-0 findings block a commit: `placeholder`, `commented-out`, `junk-file`, `secret`, `boundary` and `dependency`. If the repository's hooks are shared or hand-written (husky, `core.hooksPath`), `init` leaves them alone and prints `add to : `; tell the user. To see what the gate sees, run `lessloc check --staged`. To see everything since the branch left main, run `lessloc check --base origin/main`. It reviews the working tree without the ratchet, so it is not what CI checks: **A stricter job**, below, says how to run CI's check locally. **CI.** `lessloc init --ci` writes `.github/workflows/lessloc.yml`, if it does not exist: ```yaml name: lessloc on: [pull_request] jobs: lessloc: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24 - run: npx --yes github:rextail-ai/lessloc#v ci --base origin/${{ github.base_ref }} ``` - `` is the version that wrote the file. `LESSLOC_SOURCE` set when running `init --ci` installs from elsewhere, such as a fork. - If the tests need a toolchain, services or dependencies, add those steps before the lessloc step, and put the install command in `setup` in `.lessloc.json`, with your edit tool ([Configure it](#configure-it)). - Tell the user to commit the file and make the lessloc job a required status check, so a pull request cannot merge while it fails. - `protect` does not reach pull requests: `check` and CI ignore it. For review of changes to `.github/workflows/` and `.lessloc.json` in pull requests, the user sets up CODEOWNERS with "Require review from Code Owners" in branch protection. - Other CI services run `npx --yes github:rextail-ai/lessloc#v ci --base origin/main` in a clone with the base branch and its history. `lessloc ci` runs `setup` when it is set, then the tests, then `check --base --ratchet --require-evidence` (`--require-evidence` only when there is a test command), and fails when any of them fails. Without `--base`, the base is `origin/$GITHUB_BASE_REF` on a GitHub pull request, and `HEAD~1` anywhere else. The check in `ci` fails only on tier-0 findings: `placeholder`, `commented-out`, `junk-file`, `secret`, `boundary`, `dependency`, `config`, `unverified` (no passing test run for the commit) and `ratchet` against a committed baseline that this lessloc version recorded. Every other finding, such as `empty-test`, `swallowed-error`, or `weakened-tests` (tier 0 only in a session), is a warning or notice on the pull request and does not fail it. What holds your turn in a session is another list, `interrupt`. **The ratchet.** By default, `ci` compares each change with its base commit, and a worse measure is a warning. For a fixed line, `lessloc baseline` writes `.lessloc-baseline.json`; committed, a worse measure fails the check. It refuses to record a worse measure without `--allow-worse ""`. **A stricter job.** `ci --strict` fails on every finding it reports. To make the workflow strict, add `--strict` at the end of its `run:` line with your edit tool. Then every `ratchet` finding fails the job too, also a worse measure against the base commit, which is otherwise a warning. `exceptions` and `lessloc-ignore` cannot accept a `ratchet` finding: fix the measure (`lessloc audit` shows where), or the user accepts the new value in a baseline and commits `.lessloc-baseline.json` (`lessloc baseline` when there is none, `lessloc baseline --allow-worse ""` when there is one); from then on a worse measure fails the job without `--strict` too. A finding below tier 0 that is tied to a line can take a `lessloc-ignore : ` comment, which you may add when the user asks; only a tier-0 finding needs the owner's `exceptions` entry ([Accept one finding](#accept-one-finding)). To run the job's check locally, with the work committed and after `git fetch origin`, run `lessloc ci --base origin/main` with the flags of the workflow's `run:` line, such as `--strict`: like the job, it runs `setup` and the tests, then checks the committed state with the ratchet. `lessloc check --base origin/main --strict` is not the same: it reviews the working tree and leaves the ratchet out. **Trailers.** With `"trailers": "verified"`, each commit made through the hooks gets a line such as `Lessloc-Verified: tests passed at this exact state (clean checkout)`. `"all"` adds `Lessloc-Open:` and `Lessloc-Unchecked:` lines. A pass counts for one exact state: stage the change and run `lessloc verify` before committing. ### Update lessloc ```sh git -C ~/.lessloc pull --ff-only node ~/.lessloc/lessloc.mjs doctor ``` - The pull updates the clone in `~/.lessloc`. Every hook event starts `~/.lessloc/hook.mjs` afresh: the hooks that `init` wrote in the home folder run it, directly or through lessloc's plugin, and Claude Code reads the plugin that install.md added from that folder too (`claude plugin list` shows `Read from: ~/.lessloc`). So the pull updates the code every hook runs. - The pull does not change what `init` wrote into a tool's own files: lessloc's hook entries (which events and tool calls run it), and the copy of the working standard for Junie, goose, Crush and Aider. In the repository, run `init` again with the flag of each tool whose `doctor` line reads `set up ()`, such as `node ~/.lessloc/lessloc.mjs init --codex --gemini` (the flags are in the [install table](#install-lessloc)); lessloc replaces its own entries with the current ones. Claude Code's plugin, `set up (Claude Code plugin)`, needs no step: a new session reads its hook list from `~/.lessloc`. - If `~/.lessloc` does not exist, there is no clone to pull and no `doctor` to run: lessloc came as a plugin its tool installed. For Claude Code (`claude plugin list` shows `lessloc@lessloc`), run: ```sh claude plugin marketplace update lessloc claude plugin update lessloc@lessloc ``` - Any other plugin or extension that a tool installed itself (`doctor` lists it as `set up (Codex plugin)`, `(Cursor plugin)`, `(Copilot plugin)`, `(Gemini CLI extension)`, `(Qwen Code extension)` or `(Pi package)`) belongs to that tool. These pages give no update step for it: tell the user so, and do not guess one. - `doctor` shows no versions. `lessloc version` prints the clone's, and `claude plugin list` shows the Claude Code plugin's. - Act on `doctor`'s lines as in [Check that it works](#check-that-it-works). Two come from a version change. For `AGENTS.md holds the rules of an earlier lessloc`, run `lessloc init --rules` with the user's agreement. For `ratchet baseline is from lessloc `, a baseline recorded by another version only informs: the user reviews the difference, then runs `lessloc baseline` to record it again. - Then tell the user to start a new session in each tool. Each hook runs lessloc from `~/.lessloc`, so open sessions run the new code from their next hook, but they keep the hooks they loaded at start (which events call lessloc and, in OpenCode, Kilo Code, Amp, Cline and Pi, lessloc's plugin) until a new session loads the current ones. ### Remove lessloc Run in each repository where `init` ran: ```sh node ~/.lessloc/lessloc.mjs remove # this clone: git hooks, lessloc's AGENTS.md block if there is one, lessloc's own files for this checkout node ~/.lessloc/lessloc.mjs remove --agents # also every agent tool's hooks, for all projects ``` Tell the user only what the output lists as removed: an AGENTS.md block, for example, exists only where `init --rules` ran. | The output says | Do this | |---|---| | `removed: ...` | Nothing. Settings files are given back as they were. | | `kept, because they are shared with the team: ...` | Delete them only if the user asks. | | `installed by the tool itself, so remove it there: ...` | Run the commands it gives. Where it names a step in a tool's settings, tell the user. | | `not removed: ...` (exit code 1) | The file has comments or is not a JSON object. Tell the user which entries to take out by hand. | | `nothing of lessloc's to remove` | Nothing. | - Outside a repository, `remove --agents` takes out only the agent tools' hooks. A tool's flag, such as `remove --codex`, takes out that tool only. - For Claude Code, the commands are `claude plugin uninstall lessloc@lessloc` and `claude plugin marketplace remove lessloc`. - For the Copilot cloud agent, `remove --copilot --repo` takes lessloc out of `.github/hooks/review.json`. - The `@AGENTS.md` line that `init --rules` may have added to CLAUDE.md stays; delete it if the user wants. - Do not delete `~/.cache/lessloc` or `~/.lessloc` from this session. An agent tool registers its hooks when a session starts, and the session keeps running them until it ends, so this session, and any other agent session that is open, keeps running lessloc's hooks, and they write lessloc's folder in `~/.cache/lessloc` again, even right after `remove` deleted it. Tell the user to close those sessions and then run `rm -rf ~/.cache/lessloc ~/.lessloc`, or to ask for it in a new session. That deletes what lessloc stored outside the repositories; nothing trims that folder once lessloc is gone. - The snapshots lessloc's hooks took stay in each repository's `.git/objects` as unreachable git objects: copies of the working tree, untracked files that git does not ignore included. `remove` and the `rm -rf` leave them. Git never pushes them, and `git gc` prunes them once they are older than `gc.pruneExpire`, two weeks by default. To delete them now, the user runs `git gc --prune=now` in each repository after every agent session has ended, or asks for it in a new session; it also deletes the repository's other unreachable objects, such as a dropped stash. Tell the user this instead of saying that nothing of lessloc remains. - If you offer to pause lessloc instead of removing it, say what stays on: `"mode": "off"` in `.lessloc.json`, or `LESSLOC=off` in the environment the agent tool starts with, silences only the agent hooks; the commit gate still checks and refuses commits, and `lessloc check` and CI still run ([Turn a check off](#turn-a-check-off)). ### Troubleshoot #### Hooks do not run Run `lessloc doctor` in the repository, then check these in order: 1. **A new session.** An agent tool registers its hooks when a session starts, so the session that installed lessloc runs without it. 2. **The tool's one-time step.** In Claude Code, `claude plugin list` must show `lessloc@lessloc` with `Status: ✔ enabled`; if it is disabled, `claude plugin enable lessloc@lessloc` with the user's agreement; `✘ failed to load` means the folder it was added from is gone (item 5). Codex runs new hooks only after the user trusts them: `/hooks` in Codex. Cline's VS Code extension runs hook files only with "Enable Hooks" on: Cline settings, Feature Settings. Amp needs `plugins: reload` (Ctrl+O), Pi `/reload`, and Gemini CLI, OpenCode 1.x and Kilo Code a restart. 3. **A switch.** `LESSLOC=off` in the environment the tool starts with, or `"mode": "off"` in `.lessloc.json`, turns every agent hook off. `LESSLOC_OFF` is not a cause: it turns single parts off, and the hooks still run and show in `doctor`. 4. **Node.** Every hook starts `node`. Node 20 or newer must be on the PATH the agent tool uses, not only in your shell. 5. **The clone.** The hooks, and Claude Code's plugin, run lessloc from `~/.lessloc`. If `ls ~/.lessloc/hook.mjs` fails, put the clone back there: move it back, or clone it again with `git clone --depth 1 https://github.com/rextail-ai/lessloc ~/.lessloc`, then start a new session. While it is missing, `claude plugin list` shows `lessloc@lessloc` as `✘ failed to load`, and `init` run from another folder does not re-point the Claude Code plugin. 6. **Errors.** A `hook error(s)` line names the latest one; the rest are in `errors.log` in this checkout's folder in the user cache, `~/.cache/lessloc/-/errors.log`, the folder `doctor` prints as `lessloc's own files`, not in `~/.lessloc`. `LESSLOC_DEBUG=1` prints them on the hook's stderr as they happen; an agent the user built shows them only if it passes the hook's stderr through. 7. **Where the tool reads hooks.** Kiro's IDE reads none of them, only its CLI. Junie runs hooks only in its CLI's Early Access build. OpenHands skips them in a repository with its own `.openhands/hooks.json`. A `crushrc` that sets hooks may hide Crush's entry. #### Node without its startup snapshot Some Linux distribution packages build Node without its startup snapshot, and such a Node starts several times slower. Every hook starts `node`: the first one on the PATH the agent tool started with. `doctor` tests the Node that runs it. 1. **Install Node from nodejs.org, or with a version manager such as nvm or fnm.** If the user asked you to fix the warning, install it with nvm or fnm yourself; neither needs root. Otherwise tell the user. 2. **Make it the first `node` on the PATH.** In a new terminal, `command -v node` prints the new Node's path, and `node -p process.config.variables.node_use_node_snapshot` does not print `false`. 3. **Restart the agent tool**, a terminal tool from a new terminal. A running tool keeps the PATH it started with, so a new session inside it still runs the old Node. 4. **Check.** `lessloc doctor`, run in the restarted tool, no longer prints the line. The per-event medians at its end do not include Node's start, so they do not show the gain; `time node -e 0`, run before and after, does. #### The agent was held for nothing 1. **Find out why.** The session's journal is `sessions/.log` in lessloc's folder, the one `doctor` prints. It has one `held` line per item, with its kind and, for the tests and the reply, the reason: `failed` or `redefined`, `admitted` or `suspected`. 2. **Choose what holds.** Leave the kind out of `interrupt` in `.lessloc.json`. 3. **Accept one case for good.** For a finding about code: an `exceptions` entry with a `why`, or a `lessloc-ignore : ` comment. Holds about the tests, requested names and your reply are not affected by these; use `interrupt`, `LESSLOC_OFF=tests`, `LESSLOC_OFF=requests` or `LESSLOC_OFF=unfinished`. 4. **Report it** at https://github.com/rextail-ai/lessloc/issues, with the command you ran and what you were told, and nothing private. #### Platforms and tools lessloc cannot hook | Where | What does not work | Tell the user | |---|---|---| | Node older than 20, or no git | lessloc does not work | lessloc needs Node 20 or newer and git; they install them, not you | | Windows, Cursor's plugin | its hooks run a shell script | use `init --cursor` instead | | Windows, Cline's VS Code extension | it runs `.ps1` hook files, which `init --cline` does not write | Cline's hooks do not run there | | Windows, Augment | it runs only `.ps1`, `.cmd` and `.bat` hooks, which `init --augment` does not write | Augment's hooks do not run there | | Windows, Aider | the lint command is a shell script | `init --aider` does not work there | | Kiro's IDE, Junie's IDE plugin, OpenHands Cloud | they do not read the user-level hooks | use the tool's CLI, or rely on the commit gate and CI | | Zed, Warp, Continue, JetBrains AI Assistant, Roo Code | no hooks lessloc can install | offer `init --rules`, the commit gate and CI; Continue and Zed differ, as the note under [Install lessloc](#install-lessloc) says | `lessloc check` and CI work whatever the agent tool. ### Connect their own agent An agent the user builds runs, once per event, in the session's working directory: ```sh node ~/.lessloc/hook.mjs --host generic ``` The agent writes one JSON object to its stdin, closes it, and reads one JSON object from its stdout. Empty output means there is nothing to do. The field names follow Claude Code's hooks: | Event | The agent sends | The agent does with the answer | |---|---|---| | every event | `hook_event_name`, `session_id`, `cwd`, and `caps: {"ask": true}` when it can put a decision to the user | | | `SessionStart` | `source`: `startup`, `resume`, `clear` or `compact` | adds `context` to the system prompt: the working standard (left out when `init --rules` put it in AGENTS.md, which the agent must then load), the project's rules and a note on work the last session left open | | `UserPromptSubmit` | `prompt`: the user's message | adds `context` to that message: usually empty, hints about existing code when the prompt names it | | `PreToolUse` | `tool_name`, `tool_input`, `tool_use_id` | `deny`: does not run the call, and returns the text as its error. `ask` (only with `caps.ask`): asks the user. `input` (for `agent`): runs the call with this input instead | | `PostToolUse` | `tool_name`, `tool_input`, `tool_use_id`, `tool_response`: `{exit_code, output}` | appends `feedback` to the result the model sees, never replacing it | | `PostToolUseFailure` | `error`, `is_interrupt` | | | `Stop` | `last_assistant_message`: the final reply, `stop_hook_active`, and `status`: `completed`, or how the turn ended otherwise | `block`: does not end the turn, sends the text as the agent's next message, then sends the next `Stop` with `stop_hook_active: true` | `tool_name` is one of `write`, `edit`, `patch`, `shell`, `delete` and `agent`, and `tool_input` has exactly the keys of its shape: `write` `{path, content}`, `edit` `{path, old_string, new_string}`, `patch` `{patch}`, `shell` `{command, workdir}`, `delete` `{path}`, `agent` `{prompt}`. The integration converts its tools' own argument names to these, for example `cmd` → `command` and `old_str`/`new_str` → `old_string`/`new_string`, and converts an `input` answer back. Other keys fail silently: a write is not checked before it runs, and a shell command is read as an empty command, so what it changed and its test run go unseen. lessloc needs `tool_use_id` to pair a call's two events and track what a shell command or patch changed. lessloc reads what a turn changed from the files on disk, by comparing snapshots of the working tree taken at the turn's start, around tool calls and at `Stop`; `tool_input` names the file a write is about, and lets lessloc refuse the call before it runs. A test run's result comes from `tool_response`. So a test of the integration must go through the agent's own tool wrapper with its tools' real arguments, not hand-written payloads, and must really run the tool between `PreToolUse` and `PostToolUse`: a stubbed write changes nothing on disk, and the turn gets no note and no `block`. Every text in the answer except `ask` is written for the model. Hook errors go to `errors.log` in the checkout's folder in the user cache, `~/.cache/lessloc/-/errors.log`, never into `~/.lessloc`. `lessloc doctor` shows them and prints that folder as `lessloc's own files`. `LESSLOC_DEBUG=1` prints them on the hook's stderr, so a wrapper that captures the hook's output must pass stderr through for them to show. Read [Custom agents and SDKs](https://lessloc.org/guide/any-agent.md) before writing the integration: it has each `tool_input` shape in full, the timeouts, a reference loop and [how to test the integration](https://lessloc.org/guide/any-agent.md#checking-your-integration) against the real hook. The Claude Agent SDK loads `~/.lessloc` as a plugin, by absolute path. The Codex SDK uses the hooks from `init --codex`. The OpenAI Agents SDK connects through the protocol, at the points [Custom agents and SDKs](https://lessloc.org/guide/any-agent.md#openai-agents-sdk) lists. When the integration is done, tell the user how to disconnect it, and never say that `remove` undoes everything: 1. Take the hook calls, and any tests of them, out of the agent's code. 2. Run `node ~/.lessloc/lessloc.mjs remove` in each repository where `init` ran. It takes lessloc out of that clone's git hooks, and out of AGENTS.md where `init --rules` ran, and deletes only that checkout's folder in the user cache. 3. Once no agent session runs, `rm -rf ~/.cache/lessloc ~/.lessloc` deletes the clone and lessloc's folders for every other repository the agent worked in, with their shortened copies of the user's prompts. If another agent tool still uses lessloc, take it out there first ([Remove lessloc](#remove-lessloc)). 4. The snapshots of the working tree stay in each repository's `.git/objects`, never pushed, until `git gc` prunes them. ## Reference ### Commands Run `lessloc` with no command to print the usage. A command that cannot start prints why and exits with 2, such as `check`, `verify`, `ci`, `init` and `remove` outside a git repository. | Command | Flags | What it does | |---|---|---| | `find ` | | up to 15 existing definitions whose names match, best first; works outside a git repository | | `audit [dir]` | `--json`, `--tools` | the whole codebase: copies, dead code, junk, comment weight, structure | | `check` | `--staged` or `--base `, `--ratchet`, `--require-evidence`, `--strict`, `--json`, `--github`, `--all` | reviews a change, by default the working tree against `HEAD`; exits with 1 when a tier-0 finding remains | | `test [-- ]` | | runs the test command with compact output, and records a run of the project's test command for this exact state | | `verify []` | | runs the tests on a clean checkout, by default of the staged state; prints `verified:` or `NOT verified:` | | `ci` | `--base `, `--strict` | `setup` when it is set, the tests, then `check --base --ratchet --require-evidence` (`--require-evidence` only when there is a test command); the base defaults to `origin/$GITHUB_BASE_REF` on a GitHub pull request, otherwise `HEAD~1` | | `probe --task []` | | asks a fresh agent, the `probe` command, where a behavior lives; each run is a paid agent session | | `baseline` | `--allow-worse ` | writes `.lessloc-baseline.json` | | `doctor` | | whether lessloc works here, and what each hook costs | | `init` | `--rules`, `--config`, `--baseline`, `--ci`, `--agents`, a tool flag, `--repo` with `--copilot` | the commit gate for this clone, and lists the agent tools; shared files and tool hooks only with their flags | | `remove` | `--agents`, a tool flag, `--repo` with `--copilot` or `--agents` | undoes `init` for this clone, and takes out the hooks of the tools named | | `hosts` | `--markdown`, `--write` | each agent tool: how it installs and what was verified live | | `catalog` | `--write` | the pattern catalog's tables | | `version` | | the version | The tool flags are `--codex`, `--cursor`, `--opencode`, `--kilo`, `--amp`, `--cline`, `--pi`, `--copilot`, `--gemini`, `--qwen`, `--droid`, `--augment`, `--kiro`, `--junie`, `--openhands`, `--goose`, `--devin`, `--crush`, `--antigravity` and `--aider`. The git hooks run `check --staged --gate` and `trailers []`; you do not. ### Files lessloc writes In the repository. Nothing goes into the working tree unless the user asks: | File | What writes it | |---|---| | `.git/hooks/pre-commit`, `.git/hooks/prepare-commit-msg` | `init`; never pushed | | `.git/objects` | the hooks, `check`, `test`, `verify` and `probe`: snapshots of the working tree, untracked files that git does not ignore included (over 1 MB skipped), as unreachable objects that `git gc` prunes and `remove` leaves; never pushed | | `AGENTS.md`, between `` markers, and an `@AGENTS.md` line in an existing CLAUDE.md | `init --rules` | | `.lessloc.json` | `init --config` | | `.lessloc-baseline.json` | `init --baseline`, `baseline` | | `.github/workflows/lessloc.yml` | `init --ci` | | `.github/hooks/review.json` | `init --copilot --repo` | | `Lessloc-Verified`, `Lessloc-Open`, `Lessloc-Unchecked` commit trailers | `prepare-commit-msg`, only when `trailers` is set | In the user cache, one folder per checkout: `~/.cache/lessloc/-/`. It holds `sessions/.log` (each session's journal: files touched, approvals asked, notes given, turns held), `sessions/.json`, `ledger/.json` (the handoff record), `evidence/.json` (test results per exact tree), `errors.log`, `cost.log` and `staged.json`. Beside the checkout folders, `originals.json` keeps what each agent tool's settings file held before `init`, so `remove` can give it back. None of it is shared with the team. Session state and handoff records keep a shortened copy of the user's prompts. Only `originals.json` is readable by the user alone; lessloc creates the other files and folders with the account's default permissions, so on a shared machine other accounts can read them where the home folder lets them. `chmod 700 ~/.cache/lessloc`, at the default location, makes them private ([In your user cache](https://lessloc.org/guide/reference.md#in-your-user-cache)). In the home folder: the files in the [install table](#install-lessloc). In the system's temporary folder: clean checkouts (`lessloc-checkout-*`) and `doctor`'s test folders (`lessloc-doctor-*`), removed when the command ends. ### Environment variables | Variable | Effect | |---|---| | `LESSLOC=off` | every agent hook does nothing | | `LESSLOC_OFF` | turns single parts off, comma-separated; the hooks still run | | `LESSLOC_STATE` | the folder lessloc keeps its files in, instead of the user cache | | `LESSLOC_DEBUG=1` | prints hook errors on the hook's stderr as they happen | | `LESSLOC_PROFILE=1` | prints each detector's time | | `LESSLOC_SOURCE` | where the workflow from `init --ci` installs lessloc from | | `XDG_CACHE_HOME`, `LOCALAPPDATA` | the user cache that lessloc's folder goes in | | `XDG_CONFIG_HOME` | replaces `~/.config` for the files `init --opencode`, `--kilo`, `--amp`, `--goose`, `--crush` and `--aider` write there (not `--devin`) | | `CODEX_HOME`, `COPILOT_HOME`, `CLINE_DIR`, `PI_CODING_AGENT_DIR`, `CRUSH_GLOBAL_CONFIG`, `OH_PERSISTENCE_DIR`, `CLAUDE_CONFIG_DIR` | where each tool keeps its settings, as the tool reads it | | `GITHUB_ACTIONS=true` | `check` prints GitHub annotations | | `GITHUB_BASE_REF` | the branch `ci` compares with when no `--base` is given | ## Read more - [llms-full.txt](https://lessloc.org/llms-full.txt): the docs in one plain-text file, and [llms-patterns.txt](https://lessloc.org/llms-patterns.txt): the pattern pages, one per kind of finding. - [llms.txt](https://lessloc.org/llms.txt): the list of pages. Each page is also plain Markdown at its own address plus `.md`. - The pages behind this one: [Getting started](https://lessloc.org/guide/getting-started.md), [How it works](https://lessloc.org/guide/how-it-works.md), [Configuration](https://lessloc.org/guide/configuration.md), [Commits and CI](https://lessloc.org/guide/ci.md), [Reference](https://lessloc.org/guide/reference.md), [FAQ](https://lessloc.org/guide/faq.md) and [Custom agents and SDKs](https://lessloc.org/guide/any-agent.md). --- # Install lessloc These steps are for an AI coding agent whose user asked it to install lessloc. Follow them in order and run each command yourself. The user only approves your commands. Where a step says to stop, stop and tell the user why. lessloc keeps coding agents to the least code that does the job and still works. It runs as hooks inside the user's agent tools. It makes no network calls. In the repository it writes the commit gate in `.git/hooks` and snapshots of the working tree in `.git/objects`, which git never pushes, and nothing into the repository's files unless the user asks. ## 1. Check the requirements Run these three commands: ```sh node --version git --version git rev-parse --show-toplevel ``` - `node` must print v20 or newer. If node is missing or older, stop. Tell the user: "lessloc needs Node 20 or newer. Install it from https://nodejs.org or with a version manager such as nvm or fnm, then ask me again." Do not install Node yourself. - `git` must print a version. If it is missing, stop and tell the user that lessloc needs git. - `git rev-parse` must print a folder. That is the repository to set up. If it fails, ask the user which repository they mean, and run the next steps there. ## 2. Get lessloc If the folder `~/.lessloc` does not exist, clone lessloc into it: ```sh git clone --depth 1 https://github.com/rextail-ai/lessloc ~/.lessloc ``` If it exists, update it: ```sh git -C ~/.lessloc pull --ff-only ``` Keep lessloc in `~/.lessloc`, because the hooks run it from there. Do not install it globally, and do not run it through npx: npx may clean its cache, and the hooks would stop. ## 3. Set up the agent tools and this repository From the repository folder, run: ```sh node ~/.lessloc/lessloc.mjs init --agents ``` This command does three things: - It adds the commit gate to this repository's `.git/hooks`. Nothing goes into the repository's files. - It finds the agent tools installed for this user. The line "this command runs in" names the tool you run in. - It installs lessloc's hooks for each tool it supports, in the user's home folder, for all their projects. Read the lines under "Agent tools for this user" and act on each: | The line says | Do this | |---|---| | `installed in ` | Nothing. It is done. | | `set up (...)` | Nothing. It was done before. | | `Claude Code: not set up ... run: ` | Run those commands yourself. They are Claude Code's own plugin commands. | | `not set up: ` | Tell the user the reason. Do not edit that file. | | `lessloc has no hooks for it` | Tell the user that this tool gets no hooks. Offer `node ~/.lessloc/lessloc.mjs init --rules`, which puts lessloc's working standard in the repository's AGENTS.md. Run it only if the user agrees, because AGENTS.md is shared with their team. | | `no agent tool found for this user` | Tell the user. If you are a custom agent, your developer can connect lessloc with the protocol at https://lessloc.org/guide/any-agent. | ### Only if the user asked for GitHub Copilot's cloud agent The cloud agent reads hooks only from the repository. From the repository folder, run: ```sh node ~/.lessloc/lessloc.mjs init --copilot --repo ``` It writes `.github/hooks/review.json` and prints one step that starts with `- run: git clone`. Put that step under `steps:` of the `copilot-setup-steps` job in `.github/workflows/copilot-setup-steps.yml`. If that file does not exist, create it with this content, and use the printed line as the step: ```yaml name: Copilot setup steps on: workflow_dispatch permissions: contents: read jobs: copilot-setup-steps: runs-on: ubuntu-latest steps: - run: git clone --depth 1 --branch v https://github.com/rextail-ai/lessloc "$HOME/.agent-standard" ``` Do not commit. Tell the user that both files must be committed to the repository's default branch, because the cloud agent reads them from there, and that everyone who works in the repository shares them. ## 4. Check that it works Run: ```sh node ~/.lessloc/lessloc.mjs doctor ``` Report each line to the user in plain words. A line that starts with `✓` works, `!` needs attention and `✗` failed. The text in parentheses is the fix. Hooks start with the next session, so right after an install these lines are expected: - `! no lessloc hook has run in this repository yet` - `✓ : set up (...); its hooks have not run in this repository yet`, for each tool that was set up. - If you run in Claude Code, its line reads `! Claude Code: set up (Claude Code plugin) after this session started, so this session runs without it`. That is expected: tell the user lessloc works from their next session. ## 5. Tell the user what you did and what happens next Tell the user each point below that applies. You may shorten the wording, but keep every fact in it. - **What you did:** lessloc is in `~/.lessloc`. Its hooks are installed for each tool that was set up, in the user's home folder, for all their projects. Two git hooks are in this repository's `.git/hooks`: `pre-commit`, the commit gate, and `prepare-commit-msg`, which adds commit trailers only when `trailers` is set in `.lessloc.json`. lessloc keeps its own files, a shortened copy of the user's prompts among them, in `~/.cache/lessloc`. Its hooks keep snapshots of the working tree, untracked files that git does not ignore included, in each repository's `.git/objects`; git never pushes them. Nothing goes into the repository's files. - **Start a new session** in each tool that was set up, so it loads lessloc. This session runs without lessloc. - **Codex only:** Codex runs new hooks only after the user trusts them once. They open `/hooks` in Codex and trust lessloc's hooks. - **Cline only:** in VS Code, Cline runs hook files only when the user turns on "Enable Hooks" once: Cline settings, Feature Settings. - **Amp only:** Amp loads the plugin after the user runs `plugins: reload` from its command palette (Ctrl+O), or restarts Amp. - **Pi only:** Pi loads the extension after the user types `/reload`, or restarts Pi. - **Kiro only:** Kiro's CLI runs the hooks in every workspace. Kiro's IDE does not read them. - **Junie only:** Junie runs hooks only in the Early Access build of its CLI. - **OpenHands only:** OpenHands reads the hooks only in a repository without its own `.openhands/hooks.json`. - **Aider only:** lessloc runs as Aider's lint command, which works only while Aider's auto-lint is on, its default. Aider's commits now run the repository's git hooks, so the commit gate checks them. - **What they will see:** the agent never sees lessloc. When an agent edits a protected file or skips the project's checks, such as with `git commit --no-verify`, Claude Code, OpenCode 2.x, Factory Droid and VS Code's agent mode show the user an approval prompt that starts with `lessloc:`. In the other tools, Codex among them, that call is refused instead, and the agent asks the user in its reply. Cline's VS Code extension and Aider neither ask nor refuse. - **What changes:** each session starts with a short working standard. Writes that add placeholders, commented-out code or junk files are refused. When the agent says it is done while the tests fail or requested work is missing, it is sent back once, in plain words. Unfinished work is noted for the next session. A commit with such problems is refused with "Not committed. Fix these first:". - **To undo it:** - In each repository where `init` ran: `node ~/.lessloc/lessloc.mjs remove --agents`. It takes lessloc out of that repository's git hooks and out of every agent tool's hooks, and deletes lessloc's files for that checkout in `~/.cache/lessloc`. Outside a repository, it takes out only the agent tools' hooks. - For Claude Code, `remove --agents` prints two more commands to run: `claude plugin uninstall lessloc@lessloc` and `claude plugin marketplace remove lessloc`. - For the cloud agent: `node ~/.lessloc/lessloc.mjs remove --copilot --repo` takes lessloc out of `.github/hooks/review.json`. - Once every agent session has ended: `rm -rf ~/.cache/lessloc ~/.lessloc` deletes lessloc's files in the user cache, the shortened prompt copies among them, and the clone. - If you offer to pause lessloc instead of removing it, say what stays on: `"mode": "off"` in `.lessloc.json`, or `LESSLOC=off` in the environment the agent tool starts with, silences only the agent hooks; the commit gate still checks and refuses commits, and `node ~/.lessloc/lessloc.mjs check` and CI still run. Only `node ~/.lessloc/lessloc.mjs remove` takes out the commit gate. - **What stays after the undo:** the snapshots of the working tree stay in each repository's `.git/objects`, never pushed, until `git gc` prunes them once they are older than `gc.pruneExpire`, two weeks by default. `git gc --prune=now`, run in the repository after every agent session has ended, deletes them now, together with the repository's other unreachable objects, such as a dropped stash. Never tell the user that `remove` or the `rm -rf` deletes everything of lessloc: the snapshots stay. More: https://lessloc.org/guide/getting-started --- # Remove lessloc These steps are for an AI coding agent whose user asked it to remove lessloc. Follow them in order and run each command yourself. The user only approves your commands. ## 1. Find lessloc ```sh ls ~/.lessloc/lessloc.mjs ``` If the file is missing, lessloc came as a plugin that an agent tool installed itself. For Claude Code, `claude plugin list` shows `lessloc@lessloc`: run `claude plugin uninstall lessloc@lessloc` and `claude plugin marketplace remove lessloc`, then go to step 3. For any other tool, tell the user to remove the lessloc plugin or extension in that tool, and stop. ## 2. Take it out Ask the user which repositories lessloc was set up in, and run this in each one, starting with the current one: ```sh node ~/.lessloc/lessloc.mjs remove --agents ``` It takes lessloc out of that repository's git hooks and out of every agent tool's hooks for all projects, gives settings files back as they were, and deletes lessloc's stored files for that checkout. Act on what it prints: | The output says | Do this | |---|---| | `removed: ...` | Nothing. | | `kept, because they are shared with the team: ...` | Delete them only if the user asks. | | `installed by the tool itself, so remove it there: ...` | Run the commands it gives. For Claude Code they are `claude plugin uninstall lessloc@lessloc` and `claude plugin marketplace remove lessloc`. Where it names a step in a tool's settings, tell the user. | | `not removed: ...` (exit code 1) | The file has comments or is not a JSON object. Tell the user which entries to take out by hand. | | `nothing of lessloc's to remove` | Nothing. | Outside a repository, `remove --agents` takes out only the agent tools' hooks. If the user set up GitHub Copilot's cloud agent, also run `node ~/.lessloc/lessloc.mjs remove --copilot --repo`, which takes lessloc out of `.github/hooks/review.json`. ## 3. Leave the last step to the user Do not delete `~/.cache/lessloc` or `~/.lessloc` from this session. An agent tool registers its hooks when a session starts, and the session keeps running them until it ends, so this session, and any other agent session that is open, keeps running lessloc's hooks after `remove`, and they would write `~/.cache/lessloc` again. ## 4. Tell the user - What was removed and what was kept, exactly as the output listed it. - To finish, close every agent session, then run `rm -rf ~/.cache/lessloc ~/.lessloc`, or ask for it in a new session. That deletes lessloc's files in the user cache, the shortened prompt copies among them, and the clone. It leaves the snapshots in `.git/objects`, as the next point says. - The snapshots lessloc's hooks took stay in each repository's `.git/objects` as unreachable git objects: copies of the working tree, untracked files that git does not ignore included. Git never pushes them, and `git gc` prunes them once they are older than `gc.pruneExpire`, two weeks by default. To delete them now, the user runs `git gc --prune=now` in each repository after every agent session has ended, or asks for it in a new session; it also deletes the repository's other unreachable objects, such as a dropped stash. Tell the user this instead of saying that nothing of lessloc remains. - If you offer to pause lessloc instead of removing it, say what stays on: `"mode": "off"` in `.lessloc.json`, or `LESSLOC=off` in the environment the agent tool starts with, silences only the agent hooks; the commit gate still checks and refuses commits, and `node ~/.lessloc/lessloc.mjs check` and CI still run. Only `node ~/.lessloc/lessloc.mjs remove` takes out the commit gate. More: https://lessloc.org/agents#remove-lessloc --- # lessloc Your AI coding agent, without the bloat Up to 36% less code and 26% lower cost, with the same results. - **Up to 36% less code.** Your agent reuses what your project already has instead of writing it again. Same results, less code to review. - **Up to 26% lower cost.** Less code to write and less to read back means fewer tokens per task. - **No junk left behind.** Placeholders, commented-out code and stray backup files never make it into your project. - **It finishes the job.** When your agent says "done" but its own tests still fail, it is sent back to fix them before it hands over. - **Works with your agent.** Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode and many more. - **Free and private.** Open source. No account. Nothing leaves your machine.
**Start now.** Tell your coding agent: `Install lessloc for this project by following https://lessloc.org/install.md`
--- # Start here ## Install it Open your coding agent in your project, such as Claude Code, Codex or Cursor, and tell it: > Install lessloc for this project by following https://lessloc.org/install.md Your agent checks your computer, installs lessloc for the coding tools it finds, and tells you what it did. Then start a new session. That is all. ## What you will notice Almost nothing. Your agent works as before and writes less code. Now and then: - when it says "done" but its own tests still fail, it is sent back to fix them first; - a placeholder, commented-out code or a stray backup file is refused, and the agent writes the real thing; - a commit with problems like these is stopped until they are fixed. ## Check that it works Tell your agent: > Check that lessloc works, as https://lessloc.org/agents describes ## Remove it Tell your agent: > Remove lessloc by following https://lessloc.org/remove.md ## Works with Claude Code, Codex, OpenCode, GitHub Copilot, Gemini CLI, Cursor and many more: see [the full list](https://lessloc.org/guide/getting-started.md#supported-agent-tools). ## Free and private Open source under the Apache-2.0 license. No account and no API key. lessloc makes no network calls, so your code and your prompts stay on your computer. ## More [How it works](https://lessloc.org/guide/how-it-works.md) · [Settings](https://lessloc.org/guide/configuration.md) · [Benchmarks](https://lessloc.org/benchmarks.md) · [Getting started, for developers](https://lessloc.org/guide/getting-started.md) --- # Getting started lessloc is a free, open-source (Apache-2.0) tool that keeps AI coding agents to the least code that does the job and still works. It runs inside your AI coding agent through the agent tool's hooks. You install it once for your agent tools. Each repository then gets a commit gate. The agent never sees lessloc: there is no command for it to learn and no file for it to read. New to lessloc? [Start here](https://lessloc.org/start.md) has it all on one short page: one sentence to install, one to remove. ## Requirements - **Node 20 or newer**, on the PATH your agent tool uses. Every hook starts `node`. - **git.** Reviews, checks and test evidence need a git repository. - **An agent tool with hooks**, from [Supported agent tools](#supported-agent-tools). Other tools get the rules tier. Recommended: - **A test command** that runs your whole suite, so agents and CI can verify. lessloc reads the agent's runs of it, and `lessloc test`, `verify` and `ci` run it. lessloc finds the common ones itself; if it finds none, set `test` in `.lessloc.json` ([Configuration](https://lessloc.org/guide/configuration.md#test)). - **ruff**, if you write Python. lessloc runs it for deeper checks when it is installed; without it, `lessloc check` lists those checks under "Not checked". - **Node from nodejs.org or a version manager** such as nvm or fnm. Some Linux distribution packages build Node without its startup snapshot, which makes every hook start several times slower. `lessloc doctor` warns when that is the case. After installing another Node, make it the first `node` on the PATH (`command -v node` in a new terminal prints it), then restart the agent tool: a running tool keeps the PATH it started with. `doctor`'s per-event medians leave out Node's start, so they do not show the gain; `time node -e 0`, run before and after, does. ## Supported agent tools | Agent tool | Install | Status | |---|---|---| | Claude Code | [plugin](https://lessloc.org/guide/getting-started.md#claude-code) | verified live on 2.1.303 | | Codex | [`init --codex` or the Codex plugin](https://lessloc.org/guide/getting-started.md#codex) | `init --codex` verified live on 0.163; the Codex plugin: tested against documented payloads | | Cursor | [`init --cursor` or the Cursor plugin](https://lessloc.org/guide/getting-started.md#cursor) | tested against documented payloads | | OpenCode 2.x | [`init --opencode`](https://lessloc.org/guide/getting-started.md#opencode-2-x) | verified live on 2.0.10 | | OpenCode 1.x | [`init --opencode`](https://lessloc.org/guide/getting-started.md#opencode-1-x-and-kilo-code) | tested against documented payloads | | Kilo Code | [`init --kilo`](https://lessloc.org/guide/getting-started.md#opencode-1-x-and-kilo-code) | tested against documented payloads | | Amp | [`init --amp`](https://lessloc.org/guide/getting-started.md#amp) | tested against documented payloads | | Cline | [`init --cline`](https://lessloc.org/guide/getting-started.md#cline) | tested against documented payloads | | Pi | [`init --pi` or the Pi package](https://lessloc.org/guide/getting-started.md#pi) | tested against documented payloads | | GitHub Copilot CLI, VS Code agent mode | [`init --copilot` or the Copilot CLI plugin](https://lessloc.org/guide/getting-started.md#github-copilot-cli-and-vs-code) | tested against documented payloads | | Copilot cloud agent | [`init --copilot --repo` and a setup step](https://lessloc.org/guide/getting-started.md#copilot-cloud-agent) | tested against documented payloads | | Gemini CLI | [`init --gemini` or the Gemini CLI extension](https://lessloc.org/guide/getting-started.md#gemini-cli) | tested against documented payloads | | Qwen Code | [`init --qwen` or the Qwen Code extension](https://lessloc.org/guide/getting-started.md#qwen-code) | tested against documented payloads | | Factory Droid | [`init --droid`](https://lessloc.org/guide/getting-started.md#factory-droid) | tested against documented payloads | | Augment (CLI, VS Code, IntelliJ) | [`init --augment`](https://lessloc.org/guide/getting-started.md#augment) | tested against documented payloads | | Kiro CLI | [`init --kiro`](https://lessloc.org/guide/getting-started.md#kiro) | tested against documented payloads | | Junie CLI (Early Access) | [`init --junie`](https://lessloc.org/guide/getting-started.md#junie) | tested against documented payloads | | OpenHands | [`init --openhands`](https://lessloc.org/guide/getting-started.md#openhands) | tested against documented payloads | | goose | [`init --goose`](https://lessloc.org/guide/getting-started.md#goose) | tested against documented payloads | | Devin CLI | [`init --devin`](https://lessloc.org/guide/getting-started.md#devin-cli) | tested against documented payloads | | Crush | [`init --crush`](https://lessloc.org/guide/getting-started.md#crush) | tested against documented payloads | | Antigravity (app, CLI, IDE) | [`init --antigravity`](https://lessloc.org/guide/getting-started.md#antigravity) | tested against documented payloads | | Aider | [`init --aider`](https://lessloc.org/guide/getting-started.md#aider) | tested against documented payloads | | Your own agent | [the hook protocol](https://lessloc.org/guide/any-agent.md) | tested against documented payloads | | Claude Agent SDK, Codex SDK | [the plugin, or `init --codex`](https://lessloc.org/guide/any-agent.md) | tested against documented payloads | | OpenAI Agents SDK | [the hook protocol](https://lessloc.org/guide/any-agent.md#openai-agents-sdk), the git hooks and CI | tested against documented payloads | | Zed, Warp, Continue, JetBrains AI Assistant, Roo Code, and other tools without hooks | [`init --rules`](https://lessloc.org/guide/getting-started.md#tools-without-hooks), the git hooks and CI | rules tier: rules written where each tool's documentation says it reads them | `lib/hosts.mjs` maps each tool's hook protocol onto one core, so every tool with hooks gets the same gate, end-of-turn review and handoff, as far as the tool lets lessloc act. Claude Code and Qwen Code also review subagents, and Claude Code also checks MCP tools that write. Each tool's section below says how lessloc works there. ## 1. Install lessloc Each tool below has up to three ways to install lessloc, in this order: 1. **Ask your agent.** Give your coding agent this sentence: > Install lessloc for this project by following https://lessloc.org/install.md [That page](https://lessloc.org/install.md) is written for agents. The agent checks Node and git, clones lessloc into `~/.lessloc`, sets up every agent tool it finds and this repository, runs `doctor`, and tells you what changes. You only approve its commands. Status: verified live before release in Claude Code 2.1.303 and OpenCode 2.0.10, from a local copy of this page and the repository: the agent installed lessloc, `doctor` passed, and the next session's placeholder write was refused. 2. **In your tool's prompt box**, where the tool installs plugins itself. 3. **In a terminal.** Get lessloc first: ```sh git clone https://github.com/rextail-ai/lessloc ~/.lessloc ``` On this site, `lessloc` stands for `node ~/.lessloc/lessloc.mjs`. Running `npm link` inside the clone puts `lessloc` on your PATH. The hooks run lessloc from this clone, so keep it where it is. If you move it, run the same `init` commands again; lessloc replaces its own entries. `lessloc init` lists the agent tools it finds and the command for each. To set up all of them at once: ```sh cd your-repo lessloc init --agents ``` This installs lessloc's hooks for every tool it finds and supports, in your home folder, and prints Claude Code's own commands for Claude Code. lessloc never edits Claude Code's settings itself. ### Claude Code 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Claude Code's prompt box: ``` /plugin marketplace add rextail-ai/lessloc /plugin install lessloc@lessloc ``` 3. In a terminal: ```sh claude plugin marketplace add ~/.lessloc claude plugin install lessloc@lessloc ``` Start a new agent session afterwards. The plugin hooks every event lessloc uses: session start, each prompt, before and after tool calls, subagents, and the end of each turn. Verified live on Claude Code 2.1.303: the standard at session start, a refused placeholder and a held turn reach the agent, and none of these texts names lessloc. The plugin also adds a `/lessloc` skill for you. It is never listed to the agent. It can `setup`, `audit`, `review`, `consolidate` and `find`. ### Codex 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Codex's prompt box, type `/plugins`, choose "Add marketplace", enter `rextail-ai/lessloc`, and install lessloc from the list. The plugin's hooks are exactly the ones `init --codex` writes. 3. In a terminal: ```sh cd your-repo lessloc init --codex ``` This adds lessloc's entries to `~/.codex/hooks.json` (in `$CODEX_HOME` when that is set), so they apply to all your projects. Either way, Codex runs new hooks only after you trust them once: `/hooks` in Codex. Use the plugin or `init --codex`, not both; `doctor` warns when both are set up. `init --codex` is verified live on Codex 0.163: the model sees the standard and the hints as developer messages, never lessloc, and the agent's own test runs are recorded. How lessloc works in Codex: - File edits arrive as patches. lessloc gates them from the patch text. - Where lessloc would ask you, the call is refused instead, and the agent is told to ask you in its reply ([why](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). - Feedback is added as context, since exit code 2 would replace the tool's output. ### Cursor 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Cursor's agent chat: ``` /add-plugin https://github.com/rextail-ai/lessloc ``` Cursor reads lessloc's Cursor manifest, `.cursor-plugin/plugin.json`. The plugin has the same hooks as `init --cursor`, run by `cursor/hook.sh` in the plugin's folder, and lists no skill to the agent. On Windows, use the terminal: the plugin's hooks run a shell script. 3. In a terminal: ```sh cd your-repo lessloc init --cursor ``` This adds lessloc's entries to `~/.cursor/hooks.json`, for all your projects. Use the plugin or `init --cursor`, not both; `doctor` warns when both are set up. How lessloc works in Cursor: - Cursor's prompt hook takes no context. Hints about existing code arrive with the first command or write of the turn instead. - As in Codex, where lessloc would ask you, the call is refused and the agent asks you instead. - The note at the end of a turn reaches the agent as a follow-up message. - Cursor also runs the hooks of the Claude Code plugins you installed. When lessloc's hooks run twice for one event with the same payload, the second run answers nothing. ### OpenCode 2.x 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --opencode ``` This writes a small plugin, `~/.config/opencode/plugins/lessloc.js` (under `$XDG_CONFIG_HOME` when that is set). It loads lessloc's bridge from your clone, for all your projects. How lessloc works in OpenCode: the review at the end of a turn reaches the agent as a follow-up message. Verified live on OpenCode 2.0.10: OpenCode loads the plugin file that `init --opencode` writes, and the rules, the hints, the gate, the notes after writes and the follow-up at the end of a turn reach the agent. No agent call touches lessloc, and the follow-up names only unfinished work. ### OpenCode 1.x and Kilo Code 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --opencode # OpenCode 1.x lessloc init --kilo # Kilo Code ``` `--opencode` writes the same plugin file for OpenCode 1.x and 2.x. `--kilo` writes `~/.config/kilo/plugin/lessloc.js` (under `$XDG_CONFIG_HOME` when that is set), which Kilo Code's CLI and its VS Code extension both load. Restart the tool afterwards. How lessloc works in OpenCode 1.x and Kilo Code: - Their plugin API cannot put a call to you. Where lessloc would ask you, the call is refused and the agent asks you instead. - The note at the end of a turn is sent as a new prompt, once per prompt, so it starts one more turn. - A subagent's own session is never held. ### Amp 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --amp ``` This writes `~/.config/amp/plugins/lessloc.js` (under `$XDG_CONFIG_HOME` when that is set), which loads lessloc's plugin from your clone, for all your projects. Then run `plugins: reload` from Amp's command palette (Ctrl+O), or restart Amp. How lessloc works in Amp: - Where lessloc would ask you, the call is refused and the agent asks you instead. - The working standard and the hints are added to your prompt as hidden text. - The note at the end of a turn is a follow-up message that starts one more turn. A subagent's thread is never held. ### Cline 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --cline ``` This writes two things, for all your projects: - `~/.cline/plugins/lessloc.js` (in `$CLINE_DIR` when that is set), a plugin for the Cline CLI and SDK. - five hook files in `~/Documents/Cline/Hooks/` for Cline's VS Code extension: `TaskStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `TaskComplete`. If one of those files is your own, `init` writes none of them and says so. In VS Code, turn on hooks once: Cline settings, Feature Settings, "Enable Hooks". How lessloc works in Cline: - The end of a run cannot be held. lessloc still reviews it and keeps the result for the next session. - In VS Code, a hook that refuses a tool call ends the agent's whole run (from Cline's source). So lessloc refuses nothing there; the commit gate and CI catch placeholders, commented-out code and junk files. - In VS Code, hook files do not get the output of the agent's commands (from Cline's source), so lessloc cannot read its test runs there. - In the CLI, where lessloc would ask you, the call is refused and the agent asks you instead. - The CLI gives a plugin hook 3 seconds. A hook that takes longer answers nothing for that call, and lessloc still records what it saw. - On Windows, Cline's VS Code extension runs `.ps1` hook files, which `init --cline` does not write. ### Pi 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Pi's prompt box: ``` !pi install git:github.com/rextail-ai/lessloc /reload ``` The `!` runs the command from the prompt box. The package loads lessloc's extension and nothing else. 3. In a terminal: ```sh cd your-repo lessloc init --pi ``` This writes `~/.pi/agent/extensions/lessloc.js` (in `$PI_CODING_AGENT_DIR` when that is set), for all your projects. Then type `/reload` in Pi, or restart it. Use the package or `init --pi`, not both; `doctor` warns when both are set up. How lessloc works in Pi: - Where lessloc would ask you, the call is refused and the agent asks you instead. - The working standard and the hints arrive as a message you do not see. - The note at the end of a turn asks for one more model request in the same run, as a message you do not see. ### GitHub Copilot CLI and VS Code 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Copilot CLI's prompt box: ``` /plugin install rextail-ai/lessloc ``` Copilot reads lessloc's Copilot manifest, `.github/plugin/plugin.json`, which comes before the Claude Code plugin's in Copilot's search order. In VS Code, use the sentence or the terminal. 3. In a terminal: ```sh cd your-repo lessloc init --copilot ``` This writes `~/.copilot/hooks/lessloc.json` (in `$COPILOT_HOME` when that is set), for all your projects. Copilot CLI and VS Code's agent mode both read it. `init --agents` finds Copilot by its `copilot` program or its `~/.copilot` folder, not by VS Code alone, so with VS Code only, run `init --copilot`. Use the plugin or `init --copilot`, not both; `doctor` warns when both are set up. How lessloc works in Copilot: - Copilot refuses every tool call when a pre-tool hook fails. So each of lessloc's hook commands first checks that node and lessloc are there, and does nothing otherwise. - Copilot drops what a prompt hook answers. Hints about existing code arrive with the first command or write of the turn instead. - As in Codex, where lessloc would ask you, the call is refused and the agent asks you instead. Copilot's autopilot mode and cloud agent cannot ask anyone. - The note at the end of a turn becomes the prompt for one more turn. - Copilot does not give the hook the agent's last reply, so lessloc cannot check what the reply says is left failing. - In VS Code's Local harness, a refusal reaches the agent as a hook error, and an approval request goes to you. ### Copilot cloud agent The cloud agent reads hooks only from the repository, so this setup is per repository. 1. Ask your agent: > Install lessloc for Copilot's cloud agent in this repository by following https://lessloc.org/install.md 2. In a terminal: ```sh cd your-repo lessloc init --copilot --repo ``` This writes `.github/hooks/review.json` into the repository, and prints one step for `.github/workflows/copilot-setup-steps.yml`. The step clones lessloc into `~/.agent-standard` at the release tag of the version that printed it. If that file does not exist yet, create it: ```yaml name: Copilot setup steps on: workflow_dispatch permissions: contents: read jobs: copilot-setup-steps: runs-on: ubuntu-latest steps: - run: git clone --depth 1 --branch v https://github.com/rextail-ai/lessloc "$HOME/.agent-standard" ``` Commit both files to the default branch: the cloud agent reads both from there. The step clones lessloc's public repository at a release tag. Each line of the hook file runs lessloc from `~/.agent-standard`, and does nothing where node or lessloc is missing, so a teammate without it is not affected. The cloud agent can read the repository's files, so the hook file and that folder have neutral names. The setup step is the one file in the repository that names lessloc. How lessloc works in the cloud agent: as in [Copilot](#github-copilot-cli-and-vs-code). Each job runs in a fresh sandbox, so the handoff note does not reach the next job. ### Gemini CLI 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Gemini CLI's prompt box: ``` /extensions install https://github.com/rextail-ai/lessloc ``` Then restart Gemini CLI. Gemini installs the extension from lessloc's latest GitHub release. In a terminal, the same is `gemini extensions install https://github.com/rextail-ai/lessloc`. 3. In a terminal: ```sh cd your-repo lessloc init --gemini ``` This adds lessloc's entries under `hooks` in `~/.gemini/settings.json`, for all your projects, and keeps your other settings. Restart Gemini CLI afterwards. Use the extension or `init --gemini`, not both; `doctor` warns when both are set up. How lessloc works in Gemini CLI: - As in Codex, where lessloc would ask you, the call is refused and the agent asks you instead. - The note at the end of a turn reaches the agent as a new prompt. ### Qwen Code 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In Qwen Code's prompt box: ``` /extensions install rextail-ai/lessloc ``` Qwen Code reads lessloc's own manifest, `qwen-extension.json`, and keeps the `/lessloc` skill off. In a terminal, the same is `qwen extensions install rextail-ai/lessloc`. Then start a new session. 3. In a terminal: ```sh cd your-repo lessloc init --qwen ``` This adds lessloc's entries under `hooks` in `~/.qwen/settings.json`, for all your projects, and keeps your other settings. Use the extension or `init --qwen`, not both; `doctor` warns when both are set up. How lessloc works in Qwen Code: where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Factory Droid 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In the prompt box, do not add lessloc's repository in `/plugins`: Droid would install lessloc's Claude Code plugin, which is written for Claude Code. Use the sentence or the terminal. 3. In a terminal: ```sh cd your-repo lessloc init --droid ``` This adds lessloc's entries to `~/.factory/hooks.json`, for all your projects. If that file does not exist and `~/.factory/settings.json` holds your hooks, it adds them there instead, because Droid then reads hooks only from its settings. How lessloc works in Droid: Droid asks you where lessloc asks, as Claude Code does. ### Augment 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In the prompt box, do not add lessloc's repository in `/plugins`: Augment would offer lessloc's Claude Code plugin, which is written for Claude Code. Use the sentence or the terminal. 3. In a terminal: ```sh cd your-repo lessloc init --augment ``` This adds lessloc's entries under `hooks` in `~/.augment/settings.json`, for all your projects, and keeps your other settings. Auggie and Augment's VS Code and IntelliJ extensions all read this file. Augment runs only script files as hooks, so the entries run `augment/hook.sh` from your clone. On Windows, Augment runs only `.ps1`, `.cmd` and `.bat` hooks, which `init --augment` does not write. How lessloc works in Augment: - Augment has no prompt hook. The agent gets no hints about existing code, and lessloc reads the turn's request when the turn ends. - Augment's hooks can refuse a call but not ask you. Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Kiro 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --kiro ``` This writes `~/.kiro/hooks/lessloc.json`. Kiro's CLI runs the hooks there in every workspace. Kiro's documentation says only its CLI reads that folder, so Kiro's IDE gets no hooks this way. How lessloc works in Kiro: - Nothing an after-tool hook says reaches the agent, so there are no notes right after a write. The review at the end of the turn still sees each write. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. - The note at the end of a turn starts one more agent turn. ### Junie 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In the prompt box, do not add lessloc's repository in `/extensions`: Junie would install lessloc's Claude Code plugin, which is written for Claude Code. Use the sentence or the terminal. 3. In a terminal: ```sh cd your-repo lessloc init --junie ``` This writes two things, for all your projects: - lessloc's entries under `hooks` in `~/.junie/config.json`. Your other settings stay. - the working standard in `~/.junie/AGENTS.md`, between `` markers, after anything already there. Junie runs hooks only in the Early Access build of its CLI. Its IDE plugin runs none. How lessloc works in Junie: - Junie does not wait for its session start hook. The working standard comes from `~/.junie/AGENTS.md`. The project's rules and the note from the last session come with your first prompt, or, in a run without the terminal UI, with the agent's first write or command. - Junie has no hook after a tool runs. So there are no notes right after a write, and lessloc cannot read the agent's test runs. At the end of a turn, lessloc reviews every file changed since the turn began, including files a command wrote and another agent's changes in the same checkout. - Junie's tool and stop hooks name no session, so they count toward the session that started last in the checkout. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### OpenHands 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --openhands ``` This adds lessloc's entries to `~/.openhands/hooks.json` (`$OH_PERSISTENCE_DIR/hooks.json` when that is set). OpenHands reads this file only for a repository that has no `.openhands/hooks.json` of its own. It applies where OpenHands runs on your machine, as its CLI does. OpenHands Cloud runs in its own sandbox, without this file. How lessloc works in OpenHands: - OpenHands only logs what its session start hook says. The working standard, the project's rules and the note from the last session come with your first message. - It only logs what an after-tool hook says, so there are no notes right after a write. lessloc still tracks each write and reads the agent's test runs. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### goose 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --goose ``` This writes two things, for all your projects: - a plugin folder, `~/.agents/plugins/review/`, with `plugin.json` and `hooks/hooks.json`. goose shows the model a plugin's name when a hook refuses a call or holds a turn, so the folder has a neutral name. If a plugin of yours already has that name, `init` leaves it alone and says so. - the working standard in `~/.config/goose/.goosehints` (under `$XDG_CONFIG_HOME` when that is set), between `` markers, after anything already there. How lessloc works in goose: - goose's hooks cannot add context. The working standard comes from `.goosehints`. The agent gets no hints about existing code, no project rules, no notes right after a write and no note from the last session. - goose gives hooks no command output, so lessloc sees only that a command failed, and cannot read the agent's test runs. - A refusal reaches the agent in goose's words: "Tool call denied by policy hook `review`:", then the reason. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Devin CLI 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In the prompt box, do not run `devin plugins install rextail-ai/lessloc`: it would install lessloc's Claude Code plugin, which is written for Claude Code. Use the sentence or the terminal. 3. In a terminal: ```sh cd your-repo lessloc init --devin ``` This adds lessloc's entries under `hooks` in `~/.config/devin/config.json` (`%APPDATA%\devin\config.json` on Windows), for all your projects, and keeps your other settings. How lessloc works in Devin CLI: - Devin gives hooks no exit code for a command. lessloc reads the test runner's own summary instead. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Crush 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --crush ``` This writes two things, for all your projects: - lessloc's entry under `hooks` in `~/.config/crush/crush.json` (in `$CRUSH_GLOBAL_CONFIG` when that is set). Your other settings stay. The entry is named `review`. - the working standard in `~/.config/crush/CRUSH.md`, between `` markers, after anything already there. Crush adds that file to every session's system prompt. Both go under `$XDG_CONFIG_HOME` when that is set. Crush merges `crush.json` with a `crushrc` in the same folder, and its source says the `crushrc` wins where both set the same key. So a `crushrc` that sets hooks may hide lessloc's entry. How lessloc works in Crush: - Crush hooks only the moment before a tool runs. The working standard comes from `CRUSH.md`. The project's rules and the note from the last session come with the agent's first write or command of each session. - A refused write is the only check during a session. There are no notes after a write, lessloc cannot read the agent's test runs, and it cannot hold the end of a turn. The commit gate and CI check the rest. - lessloc never sees a Crush turn end, so a Crush session leaves no note for the next one. - Crush runs subagents without hooks (from its documentation). - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Antigravity 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --antigravity ``` This adds a hook named `review` to `~/.gemini/config/hooks.json`, for all your projects. Antigravity's app, CLI and IDE all read that file. Your other hooks stay. If a hook of yours is already named `review`, `init` leaves the file alone and says so. How lessloc works in Antigravity: - Antigravity has no prompt hook. The agent gets no hints about existing code, and lessloc cannot check the turn's request or the agent's last reply. - Nothing an after-tool hook says reaches the agent, so there are no notes right after a write. The review at the end of the turn still sees each write. - After a command, Antigravity tells a hook only whether it failed. lessloc records a failed test run. It records no pass, because a command still running in the background looks the same. So when the agent reruns failing tests without changing code and they pass, lessloc may still hold the turn once for the failure. - The note at the end of a turn reaches the agent as a system message, and the agent keeps working. - Where lessloc would ask you, the call is refused, and the agent is told to ask you instead. ### Aider 1. Ask your agent: [the sentence above](#_1-install-lessloc). 2. In a terminal: ```sh cd your-repo lessloc init --aider ``` This writes two things, for all your projects: - a block in `~/.aider.conf.yml`, between `# >>> lessloc` and `# <<< lessloc` lines, with three settings: `lint-cmd`, `read` and `git-commit-verify: true`. A setting you already have stays yours, and `init` prints lessloc's value instead. A project's own `.aider.conf.yml` overrides these. - the folder `~/.config/agent-standard/` (under `$XDG_CONFIG_HOME` when that is set), with lessloc's lint command, `check`, and the working standard, `CONVENTIONS.md`. Aider shows its model the lint command and the names of read-only files, so neither names lessloc. `check` is a shell script, so this does not work on Windows. How lessloc works in Aider: - Aider has no hooks. lessloc runs as its lint command on each file the model edits. Placeholders, commented-out code, junk files, credentials, swallowed errors and forbidden imports come back to the model as lint errors, after the edit has landed, when you agree to fix them. Nothing is refused before it lands, but the commit gate refuses Aider's commit while a placeholder, commented-out code, a junk file or a credential remains. - Aider runs one lint command for every language, so lessloc's replaces Aider's built-in lint, its syntax check included. Aider lints only while auto-lint is on, its default. - The working standard is a read-only file in every chat. - Aider skips your git hooks by default. `git-commit-verify: true` makes its commits run them, so the commit gate checks each one. - lessloc gets no prompt, no test runs and no end of a turn from Aider: no hints, no hold and no note for the next session. ### Your own agent or SDK [Custom agents and SDKs](https://lessloc.org/guide/any-agent.md) covers the Claude Agent SDK, the Codex SDK, the OpenAI Agents SDK, and the hook protocol for an agent you build. ### Tools without hooks Zed, Warp, JetBrains AI Assistant, Roo Code and Continue have no hooks that lessloc can install. They get the rules tier: the working standard in the repository's AGENTS.md, the commit gate and CI. `lessloc init` names Zed, Warp and Continue when it finds them. 1. Ask your agent: [the sentence above](#_1-install-lessloc). The agent sets up the commit gate, and offers to write AGENTS.md. You decide, because AGENTS.md is shared with your team. 2. In a terminal: ```sh cd your-repo lessloc init --rules --ci ``` `init` adds the commit gate. `--rules` writes the working standard into AGENTS.md, and may add one line to CLAUDE.md ([what it writes](https://lessloc.org/guide/reference.md#init)). `--ci` writes the CI workflow. These are files your team shares: commit them if your team agrees. What each tool reads, from its documentation: - **Zed.** Its agent reads the first of these project files it finds: `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.github/copilot-instructions.md`, `AGENT.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`. If an earlier one exists, it does not read AGENTS.md. Zed's personal instructions file, `~/.config/zed/AGENTS.md`, applies to every project: you can copy `~/.lessloc/RULES.md`, the working standard, there instead. Claude Code, Codex and the other agents Zed runs keep their own lessloc install. - **Warp.** Its agent reads `AGENTS.md`, or `WARP.md`, at the repository's root. The CLI agents you run in Warp's terminal keep their own lessloc install. - **JetBrains AI Assistant.** Most of its agents read the repository's AGENTS.md. Claude Agent reads CLAUDE.md. `init --rules` adds `@AGENTS.md` to a CLAUDE.md that exists. AI Assistant's chat reads only its own project rules, in `.aiassistant/rules/`, which lessloc does not write. - **Roo Code.** It reads AGENTS.md beside its own `.roo/rules/`. - **Continue.** It reads rules from `.continue/rules/`, and its documentation does not say it reads AGENTS.md. Copy `~/.lessloc/RULES.md`, the working standard, into `.continue/rules/` yourself. ### Notes for every tool - `init --agents` and the tool flags (`--codex`, `--cursor`, `--opencode`, `--kilo`, `--amp`, `--cline`, `--pi`, `--copilot`, `--gemini`, `--qwen`, `--droid`, `--augment`, `--kiro`, `--junie`, `--openhands`, `--goose`, `--devin`, `--crush`, `--antigravity`, `--aider`) must run inside a git repository, because `init` always sets up that repository's git hooks too. The flags combine: `lessloc init --codex --gemini`. - Agent hooks are installed once for your user account, not per repository. After that they work in every git repository you open with that tool. The Copilot cloud agent is the exception: its hooks live in each repository. ## 2. Set up each repository ```sh cd your-repo lessloc init lessloc doctor ``` `init` adds the commit gate to this clone, and lists your agent tools. The agent-driven install and `init --agents` already did this for the repository they ran in. The [reference](https://lessloc.org/guide/reference.md#init) lists its flags for the files your whole team shares, and [every file lessloc writes](https://lessloc.org/guide/reference.md#files-lessloc-writes). ## 3. Check that it works `lessloc doctor` prints one line per check, and one per agent tool: whether lessloc is set up there, and whether its installed hooks answer. The [reference](https://lessloc.org/guide/reference.md#doctor) lists what it checks and how to read each line. Right after you install, `doctor` says that no lessloc hook has run in this repository yet. Start an agent session there and run it again. If the line stays after a session that started after the install has worked in the repository, the hooks do not run: ask your agent to go through [Hooks do not run](https://lessloc.org/agents.md#hooks-do-not-run). ## What you will notice The agent never sees lessloc, but you will see it now and then: - An approval prompt that starts with `lessloc:` when an agent edits a protected path, `.lessloc.json` or `.lessloc-baseline.json` with its edit tools, or runs a command that skips the project's checks, such as `git commit --no-verify`. Approve only what you asked for. [The gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate) says what tools that cannot ask you do instead. - An agent that keeps working once after saying it is done, because lessloc sent it back with what is unfinished, in plain words. For example: the tests fail on the final code, or the agent's own reply says part of the problem still fails. - A refused commit that says "Not committed. Fix these first:", followed by what blocks it. [How it works](https://lessloc.org/guide/how-it-works.md) explains each step. ## Everyday commands You do not need any of these for the agent hooks to work. They are for you and for CI: ```sh lessloc check # review your uncommitted work lessloc test # run the tests, recorded for this exact state lessloc verify # run the tests on a clean checkout of the staged state lessloc audit # the whole codebase: copies, dead code, junk lessloc find round money # existing definitions for a task, by name ``` The [reference](https://lessloc.org/guide/reference.md#commands) lists every command and flag. ## Update lessloc ```sh git -C ~/.lessloc pull --ff-only lessloc doctor ``` Every hook event starts `~/.lessloc/hook.mjs` afresh: the hooks that `init` installed run it from this clone, and Claude Code reads the plugin added from `~/.lessloc` from that folder. So the pull updates the code every hook runs. A plugin or extension that a tool installed from elsewhere updates through that tool: for Claude Code, `claude plugin marketplace update lessloc && claude plugin update lessloc@lessloc`. `doctor` shows no versions: `lessloc version` prints the clone's, and `claude plugin list` the Claude Code plugin's. The pull does not change what `init` wrote into a tool's own files: lessloc's hook entries (which events and tool calls run it), and the copy of the working standard for Junie, goose, Crush and Aider. Run `init` again in a repository with the flag of each tool that `doctor` lists as `set up ()`, such as `lessloc init --codex --gemini`, and lessloc replaces its entries with the current ones. The Claude Code plugin needs no step. Start a new session in each tool afterwards. Each hook runs lessloc from `~/.lessloc`, so open sessions run the new code from their next hook, but they keep the hooks they loaded at start (which events call lessloc and, in OpenCode, Kilo Code, Amp, Cline and Pi, lessloc's plugin) until a new session loads the current ones. ## Remove lessloc From inside each repository where you ran `init`: ```sh lessloc remove ``` This takes lessloc out of that repository's git hooks and AGENTS.md, and deletes lessloc's own files for that checkout. Files shared with your team stay. To take lessloc out of your agent tools as well, for all your projects: ```sh lessloc remove --agents ``` Outside a repository, this takes out only the agent tools' hooks. Plugins a tool installed itself are removed in that tool, and `remove --agents` prints how. For Claude Code: ```sh claude plugin uninstall lessloc@lessloc claude plugin marketplace remove lessloc ``` Then, once every agent session that was open during the removal has ended, delete lessloc's cache and the clone. An open session keeps running lessloc's hooks, which write to the cache again ([`remove`](https://lessloc.org/guide/reference.md#remove)). ```sh rm -rf ~/.cache/lessloc ~/.lessloc ``` That deletes what lessloc stored outside your repositories. The snapshots of the working tree it took stay in each repository's `.git/objects`, never pushed, until `git gc` prunes them. The reference lists [what `remove` deletes and keeps](https://lessloc.org/guide/reference.md#remove), flag by flag, and how to delete the snapshots sooner. To turn off only lessloc's agent hooks without removing it (the commit gate, `lessloc check` and CI keep running), see [Turning lessloc off](https://lessloc.org/guide/configuration.md#turning-lessloc-off). ## What to expect On 117 real changes to three open-source projects, lessloc wrote 27% fewer production lines per task and cost 23% less, with the same results on the projects' own tests. [Benchmarks](https://lessloc.org/benchmarks.md) has the results, task by task. ## Next - [How it works](https://lessloc.org/guide/how-it-works.md): what happens in a session, step by step - [Configuration](https://lessloc.org/guide/configuration.md): `.lessloc.json` and the switches that turn lessloc off - [Commits and CI](https://lessloc.org/guide/ci.md): the commit gate, the ratchet and the CI workflow - [Reference](https://lessloc.org/guide/reference.md): every command, finding kind and file --- # Custom agents and SDKs An agent you build yourself can use lessloc through one command: your code sends it one JSON object per event and applies the JSON answer. This page is that protocol, version 1. The agent tools in [Getting started](https://lessloc.org/guide/getting-started.md) need none of it. ## Claude Agent SDK The Claude Agent SDK runs Claude Code's hooks, so it needs no protocol work. Load the lessloc clone, `~/.lessloc`, as a plugin, with an absolute path, because the SDK does not expand `~`: ```ts import { query } from '@anthropic-ai/claude-agent-sdk'; for await (const message of query({ prompt, options: { plugins: [{ type: 'local', path: '/home/you/.lessloc' }] } })) { // lessloc's hooks run as they do in Claude Code } ``` In Python, pass `plugins=[{"type": "local", "path": "/home/you/.lessloc"}]` to `ClaudeAgentOptions`. The plugin's hooks then answer exactly as they do in Claude Code. ## Codex SDK The Codex SDK runs the Codex CLI, which reads your user-level hooks. Run `lessloc init --codex` once, and trust the hooks once in Codex (`/hooks`). Codex records that trust, so SDK runs use the same hooks. [Supported agent tools](https://lessloc.org/guide/getting-started.md#supported-agent-tools) says what was verified live in the Codex CLI. ## OpenAI Agents SDK The OpenAI Agents SDK has no hook files for lessloc to install. Connect it through [the protocol](#the-protocol), at these points: - **Session start:** send `SessionStart` before the first run, and add `context` to the agent's `instructions`. - **Each user message:** send `UserPromptSubmit`, and add `context` to that message. - **Before a function tool runs:** a tool input guardrail (`@tool_input_guardrail` in Python) sends `PreToolUse`. When the answer has `deny`, it returns `ToolGuardrailFunctionOutput.reject_content(deny)`; otherwise `allow()`. - **After a function tool runs:** send `PostToolUse` from the tool itself, and append `feedback` to what it returns. A tool output guardrail can only replace the result, and `feedback` must never replace it. - **Shell and patch tools:** the SDK's docs say `ShellTool` and `ApplyPatchTool` skip the guardrails. Wrap the code you give them to run commands and apply patches, and send `PreToolUse` before it and `PostToolUse` after it. - **End of a run:** after `Runner.run`, send `Stop` with the run's final output as `last_assistant_message`. When the answer has `block`, run once more with `block` as the next input, then send `Stop` again with `stop_hook_active: true`. The commit gate and CI work as with any tool, once `lessloc init` has run in the repository. ## The protocol Run this once per event, in the session's working directory: ```sh node ~/.lessloc/hook.mjs --host generic ``` Write one JSON object to its stdin, then close stdin. lessloc waits at most 5 seconds for it. Read one JSON object from stdout. The exit code is always 0. Empty output, a crash or a timeout means there is nothing to do: carry on as if lessloc were not there. Allow 30 seconds for `SessionStart`, `UserPromptSubmit` and `PreToolUse`, 60 seconds for `PostToolUse`, and 600 seconds for `Stop`, because the end-of-turn review runs there. ### Input Field names follow Claude Code's hooks, so a payload from Claude Code also works. Unknown fields are ignored. | Field | Events | Meaning | |---|---|---| | `hook_event_name` | all | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure` or `Stop`. Optional: `SubagentStart` and `SubagentStop` | | `session_id` | all | the same for every event of one agent session. Turn state, the once-per-turn hold and the handoff key on it | | `cwd` | all | the absolute path of the repository the agent works in | | `source` | `SessionStart` | `startup`, `resume`, `clear` or `compact` | | `prompt` | `UserPromptSubmit` | the user's message | | `tool_name` | `PreToolUse`, `PostToolUse` | one of the tool names below. Send only these; lessloc does nothing with other names | | `tool_input` | `PreToolUse`, `PostToolUse` | the call's arguments, converted to the shape below. Send exactly these keys (`file_path` also works for `path`): map your tools' own argument names to them, for example `cmd` → `command` and `old_str`/`new_str` → `old_string`/`new_string`. Other keys fail silently: a write, edit or patch is not checked before it runs, though `Stop` still reviews its change, and a shell command is read as an empty command, so what it changed and the test run it made go unseen | | `tool_use_id` | `PreToolUse`, `PostToolUse` | pairs a call's two events. lessloc needs it to track what a shell command or patch changed | | `tool_response` | `PostToolUse` | `{exit_code, output}`. `output` is what the command printed, where a test runner's summary is | | `error`, `is_interrupt` | `PostToolUseFailure` | why the call failed; `is_interrupt: true` when the user stopped it | | `last_assistant_message` | `Stop` | the agent's final reply | | `stop_hook_active` | `Stop` | `true` when this stop follows a hold lessloc asked for | | `status` | `Stop` | `completed`, or how the turn ended otherwise (for example `aborted` or `error`). lessloc holds only a completed turn | | `agent_id` | subagent events, and the calls a subagent makes | which subagent it is | | `caps` | any | `{ask: true}` when your agent can put a decision to the user. Without it, lessloc refuses such a call and tells the agent to ask the user | | `tool_name` | `tool_input` | |---|---| | `write` | `{path, content}` | | `edit` | `{path, old_string, new_string}`, or `{path, edits: [{old_string, new_string}]}` | | `patch` | `{patch}`, in the `*** Begin Patch` format | | `shell` | `{command, workdir}`; `workdir` is optional | | `delete` | `{path}` | | `agent` | `{prompt}`: the task for a subagent | ### Output Every text in the answer is written for the model, and none names lessloc. Your code must deliver each one where the table says, and never show the model that a hook produced it. | Field | Event | What your code does | |---|---|---| | `context` | `SessionStart`, `UserPromptSubmit`, `SubagentStart` | `SessionStart`: the working standard, the project's rules and a note on work the last session left open; add it to the system prompt. The standard is left out when `init --rules` put it in the repository's AGENTS.md, so your agent must then load AGENTS.md itself. `UserPromptSubmit`: usually empty; hints about existing code when the prompt names it; add it to the user's message. `SubagentStart`: a short form of the working standard; add it to the subagent's system prompt | | `deny` | `PreToolUse` | Do not run the call. Return this text to the model as the call's error | | `ask` | `PreToolUse`, only with `caps.ask` | Ask the user, and run the call only if they approve. This text is for the user and may name lessloc | | `input` | `PreToolUse` of `agent` | Run the call with this input instead: the `tool_input` you sent, with hints about existing code added to the subagent's task in `prompt`. Convert it back to your tool's own argument names | | `feedback` | `PostToolUse` | Append this text to the result the model sees. Never replace the result | | `block` | `Stop`, `SubagentStop` | Do not end the turn. Send this text to the agent as its next message, then send the next `Stop` with `stop_hook_active: true`. lessloc holds a turn at most once | ## A reference loop [test/reference-agent.mjs](https://github.com/rextail-ai/lessloc/blob/main/test/reference-agent.mjs) is an agent loop of about 40 lines, wired to lessloc through this protocol. Its core is one function: ```js import { spawnSync } from 'node:child_process'; function lessloc(cwd, event) { const r = spawnSync('node', [ENTRY, '--host', 'generic'], { cwd, input: JSON.stringify({ cwd, ...event }), encoding: 'utf8' }); try { return JSON.parse(r.stdout || '{}'); } catch { return {}; } } ``` In Python: ```python import json, subprocess def lessloc(cwd, event): r = subprocess.run(["node", ENTRY, "--host", "generic"], input=json.dumps({"cwd": cwd, **event}), capture_output=True, text=True, cwd=cwd) try: return json.loads(r.stdout or "{}") except ValueError: return {} ``` `ENTRY` is the absolute path of `hook.mjs` in the lessloc clone, such as `/home/you/.lessloc/hook.mjs`. Other SDKs and agent frameworks call it at the same points: before and after each tool call that can change files, when a session starts, with each user message, and when the agent says it is done. ## Checking your integration `test/adapters.test.mjs` runs every agent tool's row, and this protocol, through the same scenario. A placeholder write is refused. A swallowed error gets a note appended to the result. A failed and then a passing test run are recorded. The turn is held once. The next session hears what was left open. Nothing the model reads names lessloc. The payloads it sends for this protocol are in [test/fixtures/hosts/generic.json](https://github.com/rextail-ai/lessloc/blob/main/test/fixtures/hosts/generic.json). To test your own integration, in any language, run it against the real `node ~/.lessloc/hook.mjs --host generic` in a temporary git repository whose test command lessloc knows (`lessloc doctor` there shows it). Send these checks through your agent's own tool wrapper, with your tools' real arguments, not hand-written payloads: a payload that already has the protocol's keys proves nothing about how your code converts your tools' arguments. - Each of your file tools (write, edit, patch), called to add `# ... existing code ...` to a `.py` file, must come back with `deny`, and the file must stay as it was. - A turn that writes a file with your write tool, then runs the test command with your shell tool and gets a failure, must get `block` at `Stop`, and nothing at the next `Stop` with `stop_hook_active: true`. lessloc reads what a turn changed from the files on disk, by comparing snapshots of the working tree taken at the turn's start, around tool calls, and at `Stop`. `tool_input` names the file a write is about and lets lessloc refuse the call before it runs, but the change itself is read from disk. So in these checks the tool must really run between `PreToolUse` and `PostToolUse`. If your test stubs out the write, the working tree stays as it was, the turn changed nothing, and it gets no note and no `block`. A test run's result, by contrast, is read from `tool_response`, and it counts for the files as they were when the run started, so write first and test after. Hook errors are logged to `errors.log` in this checkout's folder in your user cache, `~/.cache/lessloc/-/errors.log`, not in the `~/.lessloc` clone. `lessloc doctor` shows them, and prints that folder as `lessloc's own files` ([In your user cache](https://lessloc.org/guide/reference.md#in-your-user-cache)). `LESSLOC_DEBUG=1` prints them on the hook's stderr, so a wrapper that captures output, as the reference loop does, must pass stderr through for you to see them. If your tests fake the hook instead, the fake must answer every event: `SessionStart`, `UserPromptSubmit` and `Stop` carry no `tool_input`. Status: tested against this page's payloads by `test/adapters.test.mjs`. lessloc's plugins for OpenCode, Kilo Code, Amp, Cline and Pi speak the same protocol, each under its own host name. They share `bridge/core.js`, which runs one event and returns the answer; [Supported agent tools](https://lessloc.org/guide/getting-started.md#supported-agent-tools) says what was verified in each. ## Disconnecting your agent 1. Take the hook calls out of your agent's code. 2. In each repository where you ran `lessloc init`, run `node ~/.lessloc/lessloc.mjs remove`. It takes lessloc out of that clone's git hooks, and out of AGENTS.md where `init --rules` ran, and deletes only that checkout's folder in your user cache. 3. Once no agent session runs, run `rm -rf ~/.cache/lessloc ~/.lessloc`. That deletes the clone and lessloc's folders for every other repository your agent worked in, with their shortened copies of your prompts. If another agent tool still uses lessloc, take it out there first ([Remove lessloc](https://lessloc.org/guide/getting-started.md#remove-lessloc)). The snapshots of the working tree stay in each repository's `.git/objects`, never pushed, until `git gc` prunes them ([`remove`](https://lessloc.org/guide/reference.md#remove) says how to delete them sooner). --- # How it works lessloc runs inside your agent tool as hooks: small programs the tool starts at set moments, such as when a session starts, before a file is written, or when the agent says it is done. lessloc watches what the agent already does (its prompt, its writes, its own test runs, its final message) and steps in only where acting is cheapest. The agent has no lessloc command to run, no file to write and no report to read. This page follows one session from start to handoff. To install lessloc, and for what differs in each agent tool, see [Getting started](https://lessloc.org/guide/getting-started.md). ## A session at a glance | When | What the agent gets | Tokens | |---|---|---| | Session start | The working standard, the project's own rules, and a note when the last session left work open | about 175 for the standard; again after compaction, and in OpenCode in every model request | | Each prompt | Nothing, unless a specific word of the prompt names code you already have: then up to five places that may already do part of the task | 0, or about 30 to 300 | | Before each write (edit tools, heredocs, patches) | Nothing, unless the write adds a placeholder, commented-out code or a junk file: then the write is refused. Such writes to protected paths, and the common commands that skip the project's checks, go to you for approval | 0 | | Right after a write | One line per problem, only for a credential, a swallowed error or an import the project forbids | 0, or about 40 | | After the 40th tool call of a turn | Once, a note to finish what works and say what is left, for a request that has no natural end | 0, or about 50 | | Each subagent (Claude Code) | One line of the standard, and the existing code relevant to its task, in its own prompt | about 80, plus its hints | | End of turn | Nothing when the work is done. Otherwise, once per turn, what is unfinished, up to eight items | 0, or about 55 to 135 with one item, and about 15 to 95 for each further item | The counts are estimates from the length of each text. `bench/weight.mjs` replays one scripted session through the hooks without a model: start, prompt, a search, an edit, a test run, turn end. On tomlkit at eddd27e, `node bench/weight.mjs --file tomlkit/items.py --prompt "Add a function that doubles a value"` shows lessloc adding 264 tokens: 175 at session start and 89 at the turn end, which asks for a test of the new function. In your own repository, `lessloc doctor` shows the median tokens each hook added. ## Session start The agent gets up to three things, in one block of context. **The working standard.** This is the whole text, from [RULES.md](https://github.com/rextail-ai/lessloc/blob/main/RULES.md): > Every line you leave is read again, so solve the whole task with the least new code: the shortest diff that does all of it correctly wins. Project instructions take precedence. > > Take the first that works: not needed, already in this codebase, the standard library or platform, an installed dependency, new code. One implementation per rule. No wrapper, option, flag, config, interface or file nobody asked for; comment only a why the code cannot show. Delete what your change makes dead. Never cut correctness, security, tests or anything asked for; a case of the task you know still fails, or that your change broke, is unfinished. New behavior gets one focused test; reproduce a reported bug first. "Project instructions take precedence" means your own CLAUDE.md or AGENTS.md wins where the two disagree. If `lessloc init --rules` already put this exact text in AGENTS.md, it is not sent again, because the agent tool loads AGENTS.md itself. In Claude Code that holds unless the repository has a CLAUDE.md that does not import AGENTS.md, which is why `init --rules` adds an `@AGENTS.md` line to an existing CLAUDE.md. **The project's own rules**, when `.lessloc.json` sets `protect` or `boundaries`, as one line: ```text In this project: only the user changes `migrations/**`; `shop/**` must not import `flask` (the shop core stays framework-free). ``` **A note from the last session**: what it left uncommitted. See [The handoff](#the-handoff). When Claude Code compacts a long conversation, the standard and the project's rules are given again. ### Why the standard is short Every rule in the standard is something the agent may act on in every task, and acting costs tokens. A rule to prove that a test fails first can end up applied to new features too, a step that lists every place a change must reach makes agents think longer before their first edit, and a closing report makes them count lines. So the standard asks to reproduce a bug first only when one is reported, has no planning step, and asks for no closing report. [Benchmarks](https://lessloc.org/benchmarks.md#results) has the results of lessloc with this standard. ## Each prompt: what your code already has When a specific word of the prompt matches the name of something your code defines, the agent gets up to five places that may already do part of the task. For example, for the task "Bug: amounts that end in half a cent round down": ```text Existing code that may already do part of this (matched by name; search for other names too): - shop/money.py:1 def to_money(value): - shop/money.py:5 def format_money(amount): - shop/payouts.py:6 def round_payout(value): ``` - Matching uses names and doc words. When nothing matches, nothing is sent: no map of the codebase, no guesses. - Definitions are indexed for [these languages](https://lessloc.org/guide/faq.md#which-agent-tools-and-languages-does-it-support). The index is kept in lessloc's own folder, outside your repository, and updated file by file as files change. - In Claude Code, a subagent gets the hints for its own task inside its own prompt, so parallel subagents never receive each other's. See [Reimplemented helpers](https://lessloc.org/patterns/reuse-helper.md) and [Findability](https://lessloc.org/patterns/index.md#findability). ## Before each write: the gate The gate sees writes made with Write, Edit and MultiEdit, files a shell command writes from a heredoc (`cat > file < # Configuration lessloc works without any configuration. When a project needs its own rules, its owner writes them in `.lessloc.json` at the root of the repository. This page lists every key with its default, how to accept or suppress a finding, and the switches that turn lessloc off. ## Who owns `.lessloc.json` The file is the project owner's policy. Commit it, so that every teammate, every agent and CI use the same settings. [`mode`](#mode), [`interrupt`](#interrupt) and [`protect`](#protect) act only in agent sessions: the commit gate, `lessloc check` and CI do not read them. - **Agents need your approval to edit it.** In the default `block` mode, an agent's edit to `.lessloc.json` or `.lessloc-baseline.json` goes to you for approval ([the gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). CI and code review are the authority over this file. - **Mistakes never reach the agent.** An unknown key, a wrong type or broken JSON is reported by `lessloc doctor`, and by `lessloc check` as a tier-0 `config` finding, so CI fails until it is fixed. A misspelled key gets a "did you mean" suggestion. The pre-commit gate never refuses a commit over it. - **A key with a problem is ignored, and its default applies.** If the file is not valid JSON, every default applies. An exception without a reason is dropped; the other exceptions still apply. - **To start one,** run `lessloc init --config` in your terminal. It writes the file with the test command lessloc found. An agent creates it with its edit tool instead, so you approve it: in a session, a change to `.lessloc.json` or `.lessloc-baseline.json` made by a shell command, such as `lessloc init --config` or `lessloc baseline`, is a tier-0 `policy` finding and holds the agent's turn. ## Example ```json { "test": "npm test", "setup": "npm ci", "protect": ["migrations/**", ".github/workflows/**"], "boundaries": [ { "files": "src/ui/**", "mustNotImport": "/db/", "why": "the UI reads data through src/api" } ], "approvedDependencies": ["express", "zod", "vitest"], "exceptions": [ { "path": "legacy/**", "finding": "narration", "why": "vendored file, kept verbatim" } ], "trailers": "verified" } ``` ## All keys | Key | Default | Value | |---|---|---| | [`test`](#test) | discovered | a shell command | | [`setup`](#setup) | none | a shell command | | [`testTimeout`](#testtimeout) | `300` | seconds | | [`probe`](#probe) | none | a shell command | | [`mode`](#mode) | `"block"` | `"block"`, `"warn"` or `"off"` | | [`interrupt`](#interrupt) | the kinds marked "stops" in the [reference](https://lessloc.org/guide/reference.md#finding-kinds) | a list of finding kinds | | [`trailers`](#trailers) | `"off"` | `"verified"`, `"all"` or `"off"` | | [`protect`](#protect) | `[]` | a list of path globs | | [`boundaries`](#boundaries) | `[]` | a list of `{ "files", "mustNotImport", "why" }` | | [`approvedDependencies`](#approveddependencies) | `null` | a list of package names, or `null` | | [`allow`](#allow) | `[]` | a list of path globs | | [`exceptions`](#exceptions) | `[]` | a list of `{ "path", "finding", "why" }` | | [`maxCommentRatio`](#thresholds) | `0.25` | a number | | [`maxNetLines`](#thresholds) | `150` | a number | | [`maxNewFiles`](#thresholds) | `3` | a number | | [`dupLines`](#thresholds) | `6` | a number | | [`instructionBudget`](#thresholds) | `2000` | a number | Path globs are relative to the repository root and use `/`. `*` matches within one folder, `**` across folders, and `?` one character. A glob must match the whole path: `*.sql` matches only files at the root, and `**/*.sql` matches them anywhere. ## Verifying the work ### `test` ```json { "test": "python3 -m pytest -q" } ``` The command that runs your whole test suite. An agent's passing whole-suite run on its final state is recorded as evidence for that exact state, which the handoff reports, and the commit trailers too when [`trailers`](#trailers) is set. `lessloc test`, `verify` and `ci` run this command. When `test` is not set, lessloc uses the first of these that fits: 1. `npm test`, when package.json has a real `test` script 2. `make test`, when the Makefile has a `test:` target 3. `cargo test`, when there is a Cargo.toml 4. `go test ./...`, when there is a go.mod 5. `python3 -m pytest -q`, when there is a pytest.ini, tox.ini or conftest.py, or a `[tool.pytest` section in pyproject.toml 6. for a `tests` or `test` folder with `test_*.py` files: `python3 -m unittest discover -s tests -q` (or `-s test`) when the first file uses unittest, otherwise `python3 -m pytest -q` On Windows, `python` replaces `python3`. `lessloc init` and `lessloc doctor` show the command in use. Set `test` yourself when this guesses wrong, or when your suite runs through a script. A passing run of the configured command counts as the whole suite, and so does a whole-suite run of the same runner: for `uv run pytest -q`, also `pytest`, `python3 -m pytest -x` or `uv run pytest`. Only arguments that select a part, such as a file path or `-k`, narrow a run. A script lessloc cannot see into, such as `./scripts/test.sh`, counts only when it is run itself. With a discovered command, a run counts when it tests the language the turn changed; set `test` to make it exact ([what counts as evidence](https://lessloc.org/guides/verification.md#what-counts-as-evidence)). ### `setup` ```json { "setup": "npm ci" } ``` How a fresh checkout installs its dependencies. `lessloc verify` runs it in the clean checkout before the tests, and `lessloc ci` runs it before anything else. What `verify` does without it is under [`verify`](https://lessloc.org/guide/reference.md#verify). ### `testTimeout` ```json { "testTimeout": 900 } ``` How many seconds a test run may take in `lessloc test`, `verify` and `ci`. lessloc never runs your tests during an agent session, so this never limits the agent. ### `probe` ```json { "probe": "claude -p --output-format json" } ``` The agent command that [`lessloc probe`](https://lessloc.org/guide/reference.md#probe) runs to ask a fresh agent where a behavior lives. The question arrives on stdin. Without `probe`, `check` lists the handoff probe under "Not checked". ## How lessloc acts ### `mode` ```json { "mode": "warn" } ``` - `"block"` (default): the gate before writes and commands is on. It refuses placeholders, commented-out code and junk files, and sends policy edits and the common commands that skip the project's checks to you for approval ([the gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). - `"warn"`: nothing is refused or sent to you for approval before a write or a command. The notes after writes and the review at the end of each turn still run, and can still send the agent back. - `"off"`: the agent hooks do nothing in this repository. The git hooks, `lessloc check` and CI still run. ### `interrupt` The finding kinds that hold an agent's turn: the agent is sent back once, with what it left unfinished (at most eight items). The default list is the kinds marked "Stops" in the [reference](https://lessloc.org/guide/reference.md#finding-kinds), which describes every kind. Your list replaces the default; it is not added to it. Findings of other kinds never hold a turn: they are kept for the next session's handoff, and `check` reports them. A name that is not a finding kind makes the whole list invalid, and the default applies. This keeps the default, drops `untested` and adds `near-copy`: ```json { "interrupt": [ "tests-failed", "policy", "protected", "boundary", "dependency", "secret", "placeholder", "commented-out", "junk-file", "weakened-tests", "swallowed-error", "suppression", "unsafe", "made-up-api", "test-logic", "stub", "debug-leftover", "empty-test", "unaddressed", "unfinished", "copy", "versioned-copy", "unverified", "near-copy" ] } ``` ### `trailers` ```json { "trailers": "verified" } ``` What the `prepare-commit-msg` hook from `lessloc init` adds to each commit made through it: nothing with `"off"` (default), one line saying what was verified at exactly the committed state with `"verified"`, and also what was left open and not checked with `"all"`. [Trailers](https://lessloc.org/guide/ci.md#trailers) shows each line. ## Project policy ### `protect` ```json { "protect": ["migrations/**", ".github/workflows/**", "LICENSE"] } ``` Paths only you change. At session start the agent reads one line about them, such as "In this project: only the user changes `migrations/**`." In the default `block` mode, an agent's write to a protected path with its edit tools, a heredoc or a patch goes to you for approval ([the gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). A change made in a session without your approval, such as with `sed -i`, is a tier-0 `protected` finding, which holds the turn when it ends. In `warn` mode nothing goes to you for approval, and every change there holds the turn. This works only in agent sessions. `lessloc check`, the commit gate and CI do not look at `protect`, so a protected path changed outside a session, or in a pull request, is not flagged. For a binding rule, set one up on your code host, such as CODEOWNERS with branch protection on GitHub. ### `boundaries` ```json { "boundaries": [ { "files": "src/ui/**", "mustNotImport": "/db/", "why": "the UI reads data through src/api" } ] } ``` Imports a part of the code must not make. Each entry needs `files` and `mustNotImport`: - `files` is a path glob. - `mustNotImport` is a regular expression, tested against each import line a change adds in those files (lines that start with `import`, `from`, `require`, `use`, `using` or `#include`, and `require(` or `import(` calls). In JSON, a literal dot is written `\\.`, as in `"app\\.db"`. - `why` is optional. It is shown with the rule. At session start the agent reads the rule, such as "`src/ui/**` must not import `/db/` (the UI reads data through src/api)". A matching import is a tier-0 `boundary` finding: the agent hears about it right after the write, the turn is held if the import is still there at the end, and `check` fails. The check reads the import lines a change adds. ### `approvedDependencies` ```json { "approvedDependencies": ["express", "zod", "vitest"] } ``` - `null` (default): a dependency added to a manifest is a tier-2 `new-dependency` note. The note says when an installed library already does the same job, or when the lockfile does not pin the new one. - A list: a dependency added that is not on the list is a tier-0 `dependency` finding. It holds the turn and fails `check`. lessloc reads package.json, composer.json, `requirements*.txt`, pyproject.toml, Cargo.toml, go.mod and Gemfile. A new version of a dependency that was already there is not a new dependency. Names are matched as the manifest writes them, such as `@types/node` or a Go module path. ### `allow` ```json { "allow": ["generated/**", "docs/api/**"] } ``` Paths lessloc leaves alone. Writes there are never refused, and the review's line, file and copy checks skip them. You do not need `allow` for generated files that say so in their first lines (`@generated`, "do not edit", "auto-generated"), or for dependency and build folders such as `node_modules`, `vendor`, `dist` and `build`, or for lockfiles. lessloc skips those already. ### `exceptions` ```json { "exceptions": [ { "path": "legacy/**", "finding": "narration", "why": "vendored file, kept verbatim" }, { "path": "src/cli.py", "finding": "reads command-line flags by hand", "why": "the target device has no argparse" } ] } ``` Findings the owner accepts. An accepted finding is not shown in a session, in `check` or in CI, and does not hold a turn; an accepted tier-0 finding no longer fails `check` or blocks a commit. This is the only way to accept a tier-0 finding from the review. An entry covers every matching finding under its `path`; a [`lessloc-ignore` comment](#suppressing-one-finding-in-the-code) covers one line. - `why` is required. An entry without one is ignored and reported as a `config` problem. - `path` is a path glob. Without it, the entry covers every file. - `finding` is a finding kind. If it is not a kind, it is a regular expression tested against the finding's message, as in the second entry above. Without `finding`, the entry accepts every finding under `path`. Exceptions apply to the findings of a review. They do not stop the refusals before a write; use [`allow`](#allow) for that. They also cannot accept `config`, `ratchet`, `tests-failed`, `unverified`, `unaddressed` or `unfinished`, which come from outside the review. The ones the check adds itself are handled where they come from: a worse ratchet measure by fixing it, or by committing a baseline that records it (`lessloc baseline`, with `--allow-worse ""` when a baseline exists; [the ratchet](https://lessloc.org/guide/ci.md#the-ratchet)), a `config` finding by fixing `.lessloc.json`, and a missing test run by running the tests. What `check` then shows: - `check` reviews only the change: by default the working tree against HEAD, with `--staged` the staged change, and with `--base ` everything since the branch left ``. Line rules such as `debug-leftover`, `swallowed-error` and `stub` look only at the lines the change adds. So `check` shows or counts such a finding, as in "1 accepted by exception" on its first line, only while the change adds its line. A line already in the base (HEAD, by default) and left as it is shows nothing, before the entry or after. - Only tier-0 findings fail `check` (any finding with `--strict`). For other kinds, such as `debug-leftover`, an exception hides the finding and keeps it from holding a turn; `check` did not fail on it before. - `lessloc audit` lists the whole codebase as it is: exceptions and `lessloc-ignore` comments do not apply to it. ## Thresholds ```json { "maxNetLines": 300, "maxNewFiles": 5 } ``` Each threshold sets when a finding appears. Of these findings, only `copy` holds a turn by default. | Key | Default | Finding | Reported when | |---|---|---|---| | `maxCommentRatio` | `0.25` | `comment-density` | comments are more than this share of a file's added lines, once at least 8 comment and code lines were added. Doc comments and directives do not count. `audit` uses the same share for comment-heavy files | | `maxNetLines` | `150` | `net-growth` | a change adds more net lines than this. Tests, fixtures, examples, migrations, lockfiles and build output do not count. `0` turns the check off | | `maxNewFiles` | `3` | `new-files` | a change adds more new code files than this. Tests, fixtures, examples and migrations do not count | | `dupLines` | `6` | `copy` | a block of this many code lines in a row also exists elsewhere. Blank lines, comments, imports and lines such as a lone bracket are skipped. `audit` uses the same size for duplicate blocks | | `instructionBudget` | `2000` | `long-instructions` | always-loaded instruction files (CLAUDE.md, AGENTS.md, GEMINI.md, `.cursorrules` and the like) together hold more than this many tokens, estimated as characters divided by 4. Checked when a change touches one of them | ## Suppressing one finding in the code A `lessloc-ignore : ` comment, on a finding's line or on the line above, suppresses that finding wherever lessloc reviews a change: the end-of-turn hold in every agent session, `check` and CI. ```python try: import uvloop uvloop.install() # lessloc-ignore swallowed-error: uvloop is an optional speedup except Exception: pass ``` - The reason is required. - Without a kind (`lessloc-ignore: `), the comment suppresses any finding on that line. - Only findings tied to a line can be suppressed this way, and never tier-0 findings. Only the owner accepts those, through [`exceptions`](#exceptions). - A suppression stays visible. `check` counts it on its first line and lists each one with its reason, and in GitHub Actions each one shows as a notice on the diff: ```text suppressed: [swallowed-error] shop/loop.py:5: swallowed error: let it reach a boundary that handles it, or catch only the error you expect (uvloop is an optional speedup) ``` An agent sent back for something it intended can just say why in one line instead; an item held once is not held again in that session. For a case that comes back session after session, use the comment or an [exception](#exceptions). ## Turning lessloc off | To turn off | Do this | |---|---| | lessloc in the agent, for one run | Start the agent tool with `LESSLOC=off`, for example `LESSLOC=off claude` | | lessloc in the agent, for a repository | Set `"mode": "off"` in `.lessloc.json` | | Refusals and approval prompts before writes | Set `"mode": "warn"`; the end-of-turn review still runs | | One part | Set `LESSLOC_OFF` to a comma-separated list of parts ([below](#lessloc-off-single-components)) | | Holds for some kinds | Leave them out of [`interrupt`](#interrupt) in `.lessloc.json` | | Some paths | List them in [`allow`](#allow) in `.lessloc.json` | | The commit gate, trailers, the rules block in AGENTS.md and lessloc's files | Run `lessloc remove` in the repository | | Everything | Also run `lessloc remove --agents`, and remove the Claude Code plugin. Then, once every agent session has ended, run `rm -rf ~/.cache/lessloc ~/.lessloc`. That deletes the stored prompt copies and the clone ([Remove lessloc](https://lessloc.org/guide/getting-started.md#remove-lessloc)) | ### `LESSLOC=off` and `"mode": "off"` | Switch | Where | Effect | Takes effect | |---|---|---|---| | `LESSLOC=off` | the environment your agent tool starts with | every agent hook does nothing, for that tool's sessions | only in sessions of a tool started with it | | `"mode": "off"` | `.lessloc.json` | every agent hook does nothing in this repository, for everyone | at the next hook call, in sessions that are already open too | Every hook call starts lessloc afresh and reads `.lessloc.json` again, so any change to the file, `"mode"` included, applies from the agent's next tool call, without a new session. Written during a session, `"mode": "off"` silences lessloc from that session's next tool call on; setting `"mode": "block"` or deleting the key turns it back on the same way. `LESSLOC=off` is read from the environment the agent tool started with, so it takes effect only when the tool is started with it, as in `LESSLOC=off claude`. Neither switch touches the git hooks, `lessloc check` or CI. In the default `block` mode, if an agent puts `LESSLOC=off` or `LESSLOC_OFF=…` in front of its own commands, the command goes to you for approval ([the gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). For enforcement, make `lessloc ci` a required check ([what each layer stops](https://lessloc.org/guide/ci.md#what-each-layer-stops)). An agent's shell can still change `.lessloc.json` or `.git/hooks` in ways the hooks do not parse; the required check reviews every pull request either way. ### `LESSLOC_OFF`: single components `LESSLOC_OFF` takes a comma-separated list of components to switch off, and leaves the rest on: ```sh LESSLOC_OFF=reuse,handoff claude ``` | Component | What it switches off | |---|---| | `rules` | the working standard at session start, and its short form for each subagent. The project's `protect` and `boundaries` rules are still given | | `handoff` | the note at session start about the last session's uncommitted work | | `reuse` | the hints about existing code with each prompt and in each subagent's prompt | | `gate` | the refusals and approval prompts before writes and commands, as `"mode": "warn"` does | | `lines` | the one-line note right after a Write or Edit, about a credential, a swallowed error or a forbidden import | | `shell` | the same note after shell commands, patches and other tools that change files. The agent's test runs are still read | | `review` | the whole review at the end of each turn and of each subagent: no hold, and no handoff record | | `tests` | judging the agent's test runs at the end of a turn: nothing is held for tests that failed, or for a change to how the tests run | | `requests` | the check that the functions, options and flags a request asks for exist (`unaddressed`) | | `unfinished` | the check of the agent's last reply for part of the request it says still fails (`unfinished`) | | `pace` | the one note in a turn that passes 40 tool calls, to finish what works and say what is left | `LESSLOC_OFF` is for narrowing down a problem, and for measuring what each component is worth. The other environment variables lessloc reads are listed in the [reference](https://lessloc.org/guide/reference.md#environment-variables). --- # Commits and CI This page covers the commands for commits and CI: `check`, the commit gate, test evidence, `ci`, the ratchet and commit trailers. In a session, lessloc's hooks fail open: a bug in lessloc never blocks the agent. A git hook can be skipped with `--no-verify`. The check that binds is CI, run as a required check on every pull request. ## Review a change: `lessloc check` ```sh lessloc check # uncommitted work: the working tree, untracked files included, against HEAD lessloc check --staged # what is staged, against HEAD; this is what the commit gate sees lessloc check --base origin/main # everything since the branch left origin/main, up to the working tree ``` For example: ```text lessloc check (+8 −0 lines, 0 new files; tests not run at this exact state): 5 finding(s) - [commented-out] shop/money.py:14: commented-out code: delete `# total = sum(xs)` - [swallowed-error] shop/money.py:12: swallowed error: let it reach a boundary that handles it, or catch only the error you expect - [untested] shop/money.py: this change adds behavior (shop/money.py) and no test changed: add or extend a test that drives it the way users do and asserts the result; if no behavior changed, say so (Google eng-practices: tests ship with the change) - [unused] shop/money.py:9: new `load` is never called, imported or tested: delete it, or wire it in (CWE-561: dead code) - [vague-name] shop/money.py:9: vague name `load`: name it after what it does, so the next agent can find it Tier-0 findings (or any finding with --strict) fail the check. Not checked: handoff probe (no "probe" command) ``` - The first line gives the size of the change, whether the tests passed at exactly this state, and how many findings were accepted by exception or suppressed. - The review's findings are sorted by tier, tier 0 first. What the check adds itself (a missing test run, a worse ratchet measure, a broken setting) comes before them. Each finding shows its kind and, where there is one, the standard behind it. Agents never see this format. - "Not checked" lists what the check could not cover, so a clean result never reads as "nothing wrong": tests without a test command, languages without definition or shorter-idiom checks, deeper Python checks without ruff, the handoff probe without a `probe` command. Tier-0 findings fail the check. Here those are `placeholder`, `commented-out`, `junk-file`, `secret`, `boundary` (with `boundaries` set), `dependency` (with `approvedDependencies` set) and `config`; with `--ratchet`, also `ratchet` against a committed baseline that this lessloc version recorded; and with `--require-evidence`, `unverified`. Every other finding, such as `empty-test`, `swallowed-error`, or `weakened-tests` (tier 0 only in an agent session), is reported and does not fail the check, unless you pass `--strict`. What holds an agent's turn is another list, [`interrupt`](https://lessloc.org/guide/configuration.md#interrupt). The [Reference](https://lessloc.org/guide/reference.md#finding-kinds) lists every kind with its tier, and [`check`](https://lessloc.org/guide/reference.md#check) there lists every flag and the exit codes. ## The commit gate `lessloc init` installs two git hooks in this clone's `.git/hooks`. Git never pushes that folder, so nothing changes for your teammates. This is `pre-commit`, with the absolute path of your lessloc clone: ```sh #!/bin/sh if command -v node >/dev/null 2>&1 && [ -f "/path/to/lessloc/lessloc.mjs" ]; then node "/path/to/lessloc/lessloc.mjs" check --staged --gate || exit 1; fi ``` `prepare-commit-msg` runs `lessloc trailers "$1" "$2"` the same way ([trailers](#trailers)). Each line runs only where lessloc is installed at that path, so a hook copied to another machine does nothing instead of blocking commits. `check --staged --gate` reviews what is staged. When the commit may go ahead, it prints nothing. Otherwise it prints only what blocks the commit, in plain words, and the commit stops: ```text Not committed. Fix these first: - shop/money.py:14: commented-out code: delete `# total = sum(xs)` ``` - Only tier-0 findings block a commit: `placeholder`, `commented-out`, `junk-file`, `secret`, `boundary` and `dependency`. - The message never names lessloc or shows a kind, because whoever commits is often an agent. - A problem in `.lessloc.json` never blocks a commit. Fixing it is the owner's job, and `lessloc doctor` shows it. - The result is saved, so `prepare-commit-msg` reuses it instead of reviewing the same state twice. If your repository already has hooks you wrote, or a shared hook folder (husky, `core.hooksPath`), `init` leaves them alone and prints the line to add. `lessloc remove` takes lessloc's lines out again. `git commit --no-verify` skips the gate. In a session, in the default `block` mode, an agent's command that skips it in the common ways goes to you for approval first ([the gate](https://lessloc.org/guide/how-it-works.md#before-each-write-the-gate)). Other ways around git hooks are not recognized, so the gate is not the last line. CI is. ## Test evidence A test result counts only for the exact state of the files it ran on. With `--require-evidence`, the check fails unless a passing run is recorded for exactly the state being checked: ```text - [unverified] no passing test run recorded for exactly this state (tests not run at this exact state): run `lessloc test` ``` Results are recorded by: - the agent's own test runs in a session, read by the hooks ([what counts as evidence](https://lessloc.org/guides/verification.md#what-counts-as-evidence)); - [`lessloc test`](https://lessloc.org/guide/reference.md#test), on the working tree; - [`lessloc verify`](https://lessloc.org/guide/reference.md#verify), on a clean checkout of a commit, or of the staged state; - `lessloc ci`, on the commit being checked. ```sh lessloc test # the project's test command, recorded for the working tree as it is now lessloc test -- pytest -x # another command; its result is not recorded lessloc verify # the staged state, on a clean checkout lessloc verify HEAD~1 # one commit, on a clean checkout ``` [`verify`](https://lessloc.org/guide/reference.md#verify) in the reference says when a run counts as not verified, and how the clean checkout gets its dependencies. The working tree's state includes untracked files that git does not ignore. If your tests leave caches that are not in `.gitignore`, such as Python's `__pycache__/`, the state changes with every run, and `lessloc check --require-evidence` on the working tree finds no passing run for it. Add the caches to `.gitignore`, or run `lessloc verify` and check with `--staged`. ## CI in one command: `lessloc ci` ```sh lessloc ci --base origin/main ``` `ci` runs, in order: 1. `setup` from `.lessloc.json`, when it is set, for example `npm ci`. If it fails, `ci` fails. 2. The project's test command, when it has one, on the commit being checked. It prints `passed:` or `FAILED:` with the command, plus the output on failure, and records the result for that commit's state. 3. `check --base --ratchet --require-evidence` on the commit, with GitHub annotations inside GitHub Actions. `--require-evidence` is left out when the project has no test command. In Rust projects with cargo installed, clippy's complexity lints add findings on the lines the change wrote. They run only in `ci`, because clippy builds the crate. The base is `--base` when you give it. Without it, `ci` uses `origin/$GITHUB_BASE_REF` on a GitHub pull request, and `HEAD~1` anywhere else. `ci` fails when the setup fails, the tests fail or the check fails. The check fails only on the tier-0 kinds [listed above](#review-a-change-lessloc-check); in GitHub Actions, every other finding is a warning (tier 1) or a notice on the pull request, and the job passes. `ci --strict` passes `--strict` on to the check, so the job fails on every finding it reports. To make the workflow from `init --ci` strict, add `--strict` at the end of its `run:` line. With `--strict`: - Every `ratchet` finding fails the job, also a worse measure against the base commit, which is otherwise a warning. `exceptions` and `lessloc-ignore` comments cannot accept it. Fix the measure (`lessloc audit` shows where), or accept the new value in a baseline and commit `.lessloc-baseline.json`: `lessloc baseline` when the repository has no baseline, `lessloc baseline --allow-worse ""` when it has one. From then on the baseline is the line, and a worse measure fails the job without `--strict` too ([the ratchet](#the-ratchet)). - A finding below tier 0 that is tied to a line can be suppressed with a `lessloc-ignore : ` comment, which anyone may add, an agent too. A tier-0 finding needs the owner's entry in `exceptions` ([Accepting a finding](#accepting-a-finding)). To run the job's check on your machine, with your work committed and after `git fetch origin`, run `lessloc ci --base origin/main` with the flags of the workflow's `run:` line, such as `--strict`. Like the job, it runs `setup` and the tests, then checks the committed state with the ratchet. `lessloc check --base origin/main` checks less: it reviews the working tree, and leaves the ratchet out unless you add `--ratchet`. For example, with `--base main`, on a branch that added two functions with the same body: ```text passed: python3 -m pytest -q lessloc check (+23 −0 lines, 2 new files; tests passed at this exact state (working tree)): 6 finding(s) - [ratchet] copiedDefinitions got worse: 0 -> 1 since main (`lessloc audit` shows where) - [ratchet] duplicateLines got worse: 0 -> 6 since main (`lessloc audit` shows where) - [copy] shop/payouts.py:1: `payout_total` has the same body as `refund_total` (shop/payouts.py:10, used in 2 file(s)) with names changed: call or merge into one ... ``` ### GitHub Actions `lessloc init --ci` writes `.github/workflows/lessloc.yml`. For lessloc 0.10.0 it is: ```yaml name: lessloc on: [pull_request] jobs: lessloc: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24 - run: npx --yes github:rextail-ai/lessloc#v0.10.0 ci --base origin/${{ github.base_ref }} ``` - `fetch-depth: 0` fetches the whole history, so git can find the merge base with the base branch, and the ratchet can measure the base commit. - The job installs lessloc from GitHub at the release tag of the version that wrote the file. It never guesses a package name, because anyone could publish a package under it. To install from somewhere else, such as a fork, set `LESSLOC_SOURCE` when you run `init --ci`, for example `LESSLOC_SOURCE=github:/lessloc#v0.10.0`. - The workflow sets up Node only. If your tests need a language toolchain, services or dependencies, add those steps before the lessloc step, and put the install command in `setup` in `.lessloc.json`. - `init --ci` never overwrites a workflow file that exists. ::: tip Make it required Make the lessloc job a required status check in your branch protection or ruleset, so a pull request cannot merge while it fails. A pull request runs the workflow file, and reads `.lessloc.json`, from its own branch, so changes to them need review. `protect` does not reach pull requests: `check` and CI ignore it. To require review of changes to `.github/workflows/` and `.lessloc.json` in pull requests, use a CODEOWNERS file with "Require review from Code Owners" in branch protection. Adding `.github/workflows/**` to `protect` only affects agent sessions: lessloc asks you before an agent's edit tools change those files, and holds the turn of an agent that changes them another way. ::: ### Other CI services Any CI with Node 20 or newer and git can run the same command. The clone needs the base branch, with enough history for git to find the merge base; a full clone is simplest: ```sh npx --yes github:rextail-ai/lessloc#v0.10.0 ci --base origin/main ``` Annotations are in GitHub's format. Elsewhere, read the plain output and the exit code. ## The ratchet The ratchet keeps measures of the whole codebase from getting worse. Raw size is not one of them, because features need code. | Measure | What it counts | |---|---| | `duplicateLines` | lines in duplicate blocks | | `copiedDefinitions` | definitions with the same body under different names | | `similarNames` | functions with the same name in different files | | `unreferenced` | definitions nothing references | | `commentPercent` | comment lines that are not docs, as a percentage of code and comment lines; it may rise by 0.5 | | `commentedOut` | commented-out code and placeholders | | `swallowedErrors` | swallowed errors | | `passThroughWrappers` | wrappers that only forward to another call | | `junkFiles` | backup, copy, scratch and report files | | `instructionTokens` | tokens in always-loaded instruction files, such as CLAUDE.md and AGENTS.md | | `testAssertions` | test assertions; this one may not fall | `lessloc audit` shows where each measure comes from. **Without a baseline, the default.** `check --ratchet --base `, and so `ci`, measures the base commit in a clean checkout and compares. A worse measure is a warning on the pull request (tier 1); with `--strict`, it fails the check. Nothing needs to be committed or merged. Without `--base` and without a baseline, `--ratchet` has nothing to compare with. ```text - [ratchet] duplicateLines got worse: 0 -> 6 since main (`lessloc audit` shows where) ``` **With a committed baseline.** `lessloc baseline` writes `.lessloc-baseline.json` with the current measures. Commit it, and from then on a worse measure fails the check (tier 0): ```text - [ratchet] .lessloc-baseline.json: duplicateLines got worse: 0 -> 6 (`lessloc audit` shows where) ``` `lessloc baseline` refuses to record a worse measure unless you say why: ```sh lessloc baseline # refusing to record a worse duplicateLines (0 -> 6), copiedDefinitions (0 -> 1): fix it, or pass --allow-worse "" lessloc baseline --allow-worse "two totals stay separate until the refund rules settle" ``` The reason, the measures it raised and the date are written into the file, so the change shows up in review. A baseline recorded by another lessloc version only informs (tier 2), because versions may measure differently: once you have reviewed the difference, record it again with `lessloc baseline`. `lessloc init --baseline` writes the first baseline if there is none. ## Trailers Commit trailers are off by default. With `"trailers": "verified"` in `.lessloc.json`, each commit made through the git hooks gets one line saying what was verified at exactly that commit's state: ```text Fix half-cent rounding in to_money Lessloc-Verified: tests passed at this exact state (clean checkout) ``` The value is one of: - `tests passed at this exact state` or `tests FAILED at this exact state`, followed by where the run happened: `(working tree (run by the agent))`, `(working tree)` or `(clean checkout)`; - `tests not run at this exact state`; - `no test command`. With `"trailers": "all"`, a commit also gets `Lessloc-Open:` with the number of findings left open and the first two, and up to two `Lessloc-Unchecked:` lines for what was not checked, such as `Lessloc-Unchecked: definition checks for .zig files`. - Any later agent or person reads them with `git log`, without lessloc. - Merge and squash commits get no trailers, and a message that already has a `Lessloc-Verified:` line, as in an amend, is left alone. - Only commits made through the git hooks get trailers. A commit made elsewhere, such as in a web editor, does not. - A pass is recorded for one exact state. To have the trailer say `tests passed`, stage your change and run `lessloc verify` before you commit. Whatever the trailers setting, `prepare-commit-msg` warns when a commit message recaps the diff, listing five or more files one by one: "This commit message lists N files, which the diff already shows; say what changed and why instead." ([Commit recaps](https://lessloc.org/patterns/commit-recaps.md)) ## Accepting a finding A finding can be suppressed in place with a reason, or accepted by the owner in `.lessloc.json`; both stay visible to reviewers ([Configuration](https://lessloc.org/guide/configuration.md#exceptions)). ## What each layer stops | Layer | When it runs | Left to other layers | |---|---|---| | Agent hooks | during a session | a lessloc error, since hooks fail open; `LESSLOC=off` in the agent tool's environment; `"mode": "off"` written into `.lessloc.json` by a shell command | | Commit gate | on each commit in this clone | `git commit --no-verify` and other ways around git hooks (lessloc asks you before the common forms an agent runs); changes to paths in `protect`, which only the agent hooks check | | `lessloc ci` as a required check | on each pull request | changes to paths in `protect`, which only the agent hooks check; findings below tier 0, which it reports without failing; otherwise nothing, short of changing the workflow, the branch protection, or `.lessloc.json` and `.lessloc-baseline.json` in the pull request, which reviewers see in the diff | `lessloc ci` also covers commits from machines without the hooks. Make it a required check in your branch protection, so no branch merges without it. --- # FAQ Short answers on installing lessloc, the time and tokens it adds, where your code goes, and the agent tools and languages it supports. ## What is lessloc? lessloc is a free, open-source (Apache-2.0) tool that keeps AI coding agents to the least code that does the job and still works. It runs inside your AI coding agent through the agent tool's hooks. [How it works](https://lessloc.org/guide/how-it-works.md) follows one session from start to handoff. ## How do I install lessloc? Tell your coding agent: `Install lessloc for this project by following https://lessloc.org/install.md`. The agent follows [the install steps](https://lessloc.org/install.md). Using Claude Code? Type these two commands in its prompt box instead: ``` /plugin marketplace add rextail-ai/lessloc /plugin install lessloc@lessloc ``` [Getting started](https://lessloc.org/guide/getting-started.md#_1-install-lessloc) has the commands for every agent tool. ## Does lessloc slow my agent down? Each hook event adds some time: Node starts once, then lessloc runs the checks for that moment. `lessloc doctor` measures it in your own repository. It ends with one line per hook event, over the runs recorded there: the median time of lessloc's own work after Node has started (Node's start is not included), the tokens it added, and how many runs that covers ([doctor](https://lessloc.org/guide/reference.md#doctor)). - The hooks run at session start, on each prompt, around writes and shell commands (in Claude Code also around subagents and MCP tool calls), and at the end of each turn. They do not run on the agent tool's own read and search tools, such as Claude Code's Read, Grep and Glob. - Right after a write, only fast checks run, on the lines just written. Checks that need the whole codebase, such as finding copies, wait for the end of the turn. - lessloc never runs your tests during a session. It reads the agent's own runs. - If `doctor` warns about how your Node was built, see [Requirements](https://lessloc.org/guide/getting-started.md#requirements). ## How many tokens does it add? lessloc adds about 175 tokens at session start, for its working standard. Most other hook events add nothing, and a turn held with one unfinished item gets about 55 to 135. One scripted session added 264 tokens in all: 175 at session start and 89 at the turn end. [A session at a glance](https://lessloc.org/guide/how-it-works.md#a-session-at-a-glance) lists the tokens each hook event adds. What a rule makes agents do costs more than its tokens, which is [why the standard is short](https://lessloc.org/guide/how-it-works.md#why-the-standard-is-short). [Why your agent bill grows](https://lessloc.org/guides/costs.md) explains where the money goes. ## Does my code leave my machine? Not through lessloc. lessloc makes no network calls and has no dependencies. It is plain JavaScript that imports only Node's own built-in modules. You can check this in your clone: ```sh cd ~/.lessloc grep -rhoE "from '[^']+'" lessloc.mjs hook.mjs lib bridge opencode amp cline pi | sort -u ``` Every line it prints is either one of lessloc's own files or one of Node's own `node:` modules, and none of those is a network module such as `node:http`, `node:https`, `node:net` or `node:dns`. lessloc needs no account and no API key. **What it runs.** - During a session, the hooks run git, ruff when it is installed, and, in Python projects, `python3` once per checkout to list Python's standard-library modules. They never run your project's own commands: the agent runs the tests, and lessloc reads the result. - Commands you run yourself can run more. `test`, `verify` and `ci` run your `test` and `setup` commands. `probe` runs your `probe` agent command in a clean checkout of the repository: a paid agent session, whose model provider gets the code that agent reads. `ci` runs clippy in Rust projects. `audit --tools` runs the analyzers you have installed: knip, vulture, cargo-machete, deadcode, osv-scanner, pip-audit, cargo-audit and govulncheck. Those are separate programs with their own behavior; the vulnerability scanners, for example, look up advisories online. - The CI workflow that `init --ci` writes downloads lessloc from GitHub. **What it stores.** In the repository it adds two git hooks in `.git/hooks`, and keeps snapshots of the working tree as unreachable objects in `.git/objects`, untracked files that git does not ignore included, until `git gc` prunes them. Git never pushes either. Its own files are in your user cache, among them a shortened copy of your prompts. [Files lessloc writes](https://lessloc.org/guide/reference.md#files-lessloc-writes) lists every file, what it holds, who on your machine can read it, and whether your team gets it. `lessloc remove` deletes only the current checkout's folder in your user cache. To delete what lessloc stored outside your repositories, close every agent session and run `rm -rf ~/.cache/lessloc ~/.lessloc` ([`remove`](https://lessloc.org/guide/reference.md#remove) says how the snapshots go). Your agent tool still sends your code and prompts to its model provider. lessloc does not change that. ## The agent was held for something intended. What now? A hold costs at most one extra round per agent and turn. The agent may answer in one line why something is intended, and an item held once is not raised again in that session. [Making agents verify their work](https://lessloc.org/guides/verification.md#cases-that-are-easy-to-misread) lists the cases that are easy to misread, and how lessloc reads each. For a hold you do not want: 1. **Find out why.** The session's journal is `sessions/.log` in lessloc's folder, which `lessloc doctor` prints. It has one `held` line per item, with its kind and, for the tests and the agent's reply, the reason: `failed` or `redefined` for the tests, `admitted` or `suspected` for the reply. 2. **Choose what holds.** `interrupt` in `.lessloc.json` lists exactly the kinds that hold a turn. Leave out the ones you do not want ([Configuration](https://lessloc.org/guide/configuration.md#interrupt)). 3. **Accept one case for good.** For a finding about code, add an `exceptions` entry with a `why` ([exceptions](https://lessloc.org/guide/configuration.md#exceptions)), or a `lessloc-ignore : ` comment on its line ([suppressing one finding](https://lessloc.org/guide/configuration.md#suppressing-one-finding-in-the-code)). Holds about the tests, requested names and the agent's own reply are not affected by these; use `interrupt`, `LESSLOC_OFF=tests`, `LESSLOC_OFF=requests` or `LESSLOC_OFF=unfinished` for them. 4. **Report it** at https://github.com/rextail-ai/lessloc/issues, with the command the agent ran and what it was told. Leave out anything private. ## My project has no tests. Does lessloc still help? Yes. Most of lessloc works without tests: hints about existing code, the gate, notes right after writes, the holds that do not need tests, and `check`. Without a test command, lessloc has nothing to check the final state against, and when the agent runs no tests, the handoff says the project has no test command. If the agent runs tests anyway, their result is read and recorded, and a failing run holds the turn. Without test files either, it does not ask for a test of new behavior. `lessloc doctor` warns when there is no test command. Setting `test` in `.lessloc.json` turns on the checks that need one. ## Will "less code" make my agent cut tests or error handling? No. The standard says "Never cut correctness, security, tests or anything asked for". The hold never asks the agent to cut tests or error handling; it lists what is unfinished and says to add nothing else. [Patterns](https://lessloc.org/patterns/index.md#what-lessloc-never-pushes-an-agent-toward) lists the counterweight to each over-correction, and [Why less code](https://lessloc.org/why-less-code.md) explains what lessloc counts as unneeded. ## Which agent tools and languages does it support? lessloc works with Claude Code, Codex, OpenCode, GitHub Copilot, Gemini CLI, Cursor and many more. [Supported agent tools](https://lessloc.org/guide/getting-started.md#supported-agent-tools) lists each agent tool and how it was tested. A tool lessloc has no hooks for gets [the rules tier](https://lessloc.org/guide/getting-started.md#tools-without-hooks). An agent you build yourself connects through [the hook protocol](https://lessloc.org/guide/any-agent.md). Languages: - Definitions, used by the hints and the checks on definitions, are indexed for Python, JavaScript and TypeScript, Go, Rust, Java, Kotlin, Scala, C#, Swift, Dart, C and C++, Ruby, PHP and shell. - Shorter idioms are built in for Python, JavaScript and TypeScript, Go, Rust, Java and C#. ruff adds more for Python wherever it is installed, and clippy adds more for Rust in `ci`. - Test runs are recognized for the common test runners ([the list](https://lessloc.org/guides/verification.md#the-runners-lessloc-recognizes)). - Comment classification uses English. - In Java, Kotlin, C#, Scala and Go, the file- and folder-count checks stay quiet, because one type per file and one package per folder are those languages' conventions. The benchmarks run on Python projects, with Haiku in Claude Code ([scope](https://lessloc.org/benchmarks.md#scope)). ## How is it different from a linter? A linter checks files against rules, whoever wrote them. lessloc works on an agent's change while it happens: - before the agent writes, it points to code that already does part of the task; - when the agent writes with its edit tools, it refuses placeholders, commented-out code and junk files; - it judges only the lines the change wrote, and lines that only moved are not judged as new; - it compares new code with the whole codebase, so it finds copies under other names; - it checks the change against the request and against the agent's own test runs; - it sends the agent back once with what is unfinished, and hands the rest to the next session. Keep your linters. lessloc does not replace them, and it runs two of them for shorter idioms: ruff for Python and clippy for Rust. A linter or type checker run after the tests does not count as changing the state the tests ran on. In reports for people, findings name the standard behind them where there is one (Sonar, ESLint, ruff, clippy and others); agents never see those names. ## How do I enforce lessloc's checks? Run `lessloc init --ci` and make the lessloc job a required status check in your branch protection ([GitHub Actions](https://lessloc.org/guide/ci.md#github-actions)). That check is the one that binds. The agent hooks fail open by design, so a lessloc error never blocks your agent, and a git hook can be skipped; the required check reviews every pull request either way. [What each layer stops](https://lessloc.org/guide/ci.md#what-each-layer-stops) shows what each layer covers. ## How do I turn parts of it off? Set `LESSLOC_OFF` to a comma-separated list of parts, such as `LESSLOC_OFF=tests`, to turn those parts off. To turn lessloc off in the agent, start the agent tool with `LESSLOC=off` for one run, for example `LESSLOC=off claude`, or set `"mode": "off"` in `.lessloc.json` for a repository. [Turning lessloc off](https://lessloc.org/guide/configuration.md#turning-lessloc-off) lists every switch, from one component to everything. ## What is the license? [Apache-2.0](https://github.com/rextail-ai/lessloc/blob/main/LICENSE). --- # Reference Every command and flag, every finding kind, every file lessloc writes, and the environment variables it reads. Here `lessloc` stands for `node ~/.lessloc/lessloc.mjs` ([Getting started](https://lessloc.org/guide/getting-started.md)). Run it with no command to print the usage. ## Commands | Command | Use | |---|---| | [`find`](#find) | existing definitions for a task, by name | | [`audit`](#audit) | the whole codebase: copies, dead code, junk, comment weight, structure | | [`check`](#check) | review a change; tier-0 findings fail it | | [`test`](#test) | run the tests with compact output, recorded for this exact state | | [`verify`](#verify) | run the tests on a clean checkout of a commit | | [`ci`](#ci) | setup, tests, then `check` with the ratchet and required evidence | | [`probe`](#probe) | ask a fresh agent where a behavior lives | | [`baseline`](#baseline) | record a fixed line for the ratchet | | [`doctor`](#doctor) | is lessloc working here, and what does it cost | | [`init`](#init) | set up this clone, shared files on request, and agent tools' hooks | | [`remove`](#remove) | undo `init` and delete lessloc's own files | | [`catalog`](#catalog) | the pattern catalog's tables | | [`hosts`](#hosts) | each agent tool: how it installs and what was verified live | | [`version`](#version) | print the version | A command that cannot start prints why and exits with 2: for example `check`, `verify`, `ci`, `init` and `remove` outside a git repository, `test` and `verify` without a test command, or `probe` without a `probe` command. ### find ```sh lessloc find ``` Lists up to 15 existing definitions whose names match the words, best first, as `path:line signature`, followed by the doc comment when there is one. It matches names and doc words, so search for other names too. Definitions are indexed for [these languages](https://lessloc.org/guide/faq.md#which-agent-tools-and-languages-does-it-support). Works outside a git repository too. ```sh lessloc find round money ``` ### audit ```sh lessloc audit [dir] [--json] [--tools] ``` Reads the codebase under the folder `dir`, by default the current folder (the whole codebase when you run it at the repository's root), and lists: - duplicate blocks, definitions with the same body under other names, and same-named functions in different files - definitions nothing references, wrappers that only forward, swallowed errors - commented-out code and placeholders, stubs and mock data, silenced checks, debug leftovers, comments that narrate, comment-heavy files - backup, copy, scratch and report files, tiny files, single-entry folders, the largest files, paths deeper than 6 levels - `shortcut:` markers (deliberate debt), and builds without a lockfile - always-loaded instruction files and their tokens, lessloc's own overhead per hook, and whether the tests can be run - how many files and folders a typical commit touches, which folders change together, and the number of test assertions | Flag | Effect | |---|---| | `--json` | everything as JSON, with the ratchet's measures under `metrics` | | `--tools` | also runs these analyzers, where installed: knip, vulture, cargo-machete and deadcode, and the vulnerability scanners osv-scanner, pip-audit, cargo-audit and govulncheck | [The ratchet](https://lessloc.org/guide/ci.md#the-ratchet) lists the measures it keeps from getting worse. ### check ```sh lessloc check [--staged | --base ] [--ratchet] [--require-evidence] [--strict] [--json] [--github] [--all] ``` Reviews a change and prints its findings, ordered by tier. By default the change is the working tree, untracked files included, against `HEAD`. | Flag | Effect | |---|---| | `--staged` | review the staged state against `HEAD`, as the pre-commit hook does | | `--base ` | review everything since the merge base of `` and `HEAD` | | `--ratchet` | compare the codebase's measures with `.lessloc-baseline.json`, or, without that file, with the base commit of `--base`. A worse measure is a `ratchet` finding | | `--require-evidence` | fail with a tier-0 `unverified` finding unless a passing test run is recorded for exactly this state | | `--strict` | fail on any finding, not only on tier 0 | | `--json` | print `summary`, `verified`, `unchecked`, `findings` and `suppressed` as JSON | | `--github` | also print GitHub Actions annotations: tier 0 as errors, tier 1 as warnings, the rest and every suppression as notices. Automatic when `GITHUB_ACTIONS=true` | | `--all` | print every finding, not only the first 15 | How to read its output is under [Review a change](https://lessloc.org/guide/ci.md#review-a-change-lessloc-check). Which test runs count as evidence is under [Test evidence](https://lessloc.org/guide/ci.md#test-evidence). How hard a `ratchet` finding is depends on the line it is measured against ([the ratchet](https://lessloc.org/guide/ci.md#the-ratchet)). Exit code: 1 when a tier-0 finding remains (any finding with `--strict`), otherwise 0. ### test ```sh lessloc test [-- ] ``` Runs the project's test command with compact output: one summary line when it passes; when it fails, the failing parts and the end, at most 80 lines. A run of the project's test command is recorded as evidence for this exact working-tree state. After `--`, another command runs instead; it is recorded only if it is the project's test command. The run stops after `testTimeout` seconds. Exit code: the command's own, or 1 when it timed out. ### verify ```sh lessloc verify [] ``` Runs the test command on a clean checkout of a commit, by default of the staged state. The checkout is a temporary git worktree, removed afterwards. With `setup` set, that runs first. Without it, your `node_modules`, `.venv` or `venv` are linked in, unless a lockfile differs from the commit's: then the installed dependencies may not match, so `verify` prints `NOT verified` and asks you to set `setup` (for example `npm ci`). The result is recorded for the commit's tree as a clean-checkout run. It prints `verified:` or `NOT verified:`. Anything other than a pass, including a crash, a timeout or a failed setup, counts as not verified and exits with 1. ### ci ```sh lessloc ci [--base ] [--strict] ``` CI in one command: `setup` when it is set, then the tests, then `check --base --ratchet --require-evidence` on the commit. `--strict` passes on to the check, which then fails on any finding, `ratchet` findings included. [`lessloc ci`](https://lessloc.org/guide/ci.md#ci-in-one-command-lessloc-ci) explains each step, the base it picks, when it fails, and how to run the job's check on your machine. ### probe ```sh lessloc probe --task [] ``` Checks whether the next agent could find a change. Your `probe` command, a fresh agent with lessloc off, is asked in a clean checkout of `` (by default the staged state) where the behavior is implemented and what it would reuse. The probe passes when the answer names a code file that commit changed. It fails when the answer names none, when the agent did not run, or when it needed more than 8 turns. With JSON output, the turns and tokens come from its `num_turns` and `usage` fields. Each probe starts a paid agent session. It needs `probe` in `.lessloc.json` ([Configuration](https://lessloc.org/guide/configuration.md#probe)). ### baseline ```sh lessloc baseline [--allow-worse ] ``` Writes the codebase's current measures to `.lessloc-baseline.json`, with the lessloc version that measured them. [The ratchet](https://lessloc.org/guide/ci.md#the-ratchet) explains when a baseline helps, and how `--allow-worse` records a worse measure with a reason. ### doctor ```sh lessloc doctor ``` Says whether lessloc works in this repository. Each check prints one line that starts with `✓` (works), `!` (needs attention) or `✗` (failed). It reports: - the version of the Node that runs `doctor`, and whether it was built without its startup snapshot ([Requirements](https://lessloc.org/guide/getting-started.md#requirements) has the fix) - the git repository, and the folder outside it where lessloc keeps its files - whether `.lessloc.json` is valid, or that there is none and the defaults apply - the test command, and whether it was discovered or set - when a lessloc hook last ran in this repository - hook errors, with the latest one - whether the lessloc block in AGENTS.md is out of date, so that agents get two standards - whether the git hooks are installed - how the ratchet works here: against each change's base commit, or against a committed baseline - for each hook event, over the last 2000 hook runs recorded in this repository: the median time of lessloc's own work, timed from after Node has started and loaded lessloc, so Node's start is not included; the median tokens it added; and how many of those runs were that event - for each agent tool installed for your user: whether lessloc is set up there, whether it is set up twice (a tool's own plugin or extension and `init`), whether its installed hook command answers, and when that tool's hooks last ran in this repository. lessloc checks the command by running it once with a session start, in a temporary folder outside any repository: it must answer with the working standard, or, for a tool whose session start reaches no model, run without an error. Aider's lint command is run once on a file outside any repository, and must answer nothing. Tools that lessloc has no hooks for share one line Exit code: 1 when a check failed. ### init ```sh lessloc init [--rules] [--config] [--baseline] [--ci] [--agents] [--codex] [--cursor] [--opencode] [--kilo] [--amp] [--cline] [--pi] [--copilot] [--gemini] [--qwen] [--droid] [--augment] [--kiro] [--junie] [--openhands] [--goose] [--devin] [--crush] [--antigravity] [--aider] [--repo] ``` Without flags, `init` installs the commit gate (`pre-commit`) and the trailers hook (`prepare-commit-msg`) in this clone's git hooks folder. It writes only hook files that do not exist yet or that hold only lessloc's lines; for a shared or hand-written hook, it prints the line to add. It writes nothing into the working tree, and prints the test command and where lessloc keeps its files. It also lists the agent tools installed for your user, under "Agent tools for this user". It finds a tool by its program on your PATH or its settings in your home folder; it starts nothing and makes no network call. It names the tool it runs in from the variable each tool sets for its agent's commands ([Environment variables](#environment-variables)). For each tool, it says whether lessloc is set up there and prints the command that sets it up. Without a tool flag or `--agents`, it changes none of the agent tools' files. | Flag | Writes | |---|---| | `--rules` | the working standard in AGENTS.md, between `` and ``, replacing an earlier block. Appends `@AGENTS.md` to a CLAUDE.md that does not import it | | `--config` | `.lessloc.json` with the test command lessloc found, if the file does not exist | | `--baseline` | `.lessloc-baseline.json`, if it does not exist | | `--ci` | `.github/workflows/lessloc.yml`, if it does not exist: a workflow that runs `ci` on pull requests ([GitHub Actions](https://lessloc.org/guide/ci.md#github-actions)) | | `--agents` | what each tool flag below writes, for each of those tools that is installed, except a tool already set up through its own plugin or extension. For Claude Code, it prints the `claude plugin` commands instead: lessloc never edits Claude Code's settings | | `--codex` | lessloc's entries in `~/.codex/hooks.json` (`$CODEX_HOME/hooks.json`), for all your projects | | `--cursor` | lessloc's entries in `~/.cursor/hooks.json`, for all your projects ([status](https://lessloc.org/guide/getting-started.md#supported-agent-tools)) | | `--opencode` | the bridge plugin `~/.config/opencode/plugins/lessloc.js` (under `$XDG_CONFIG_HOME` when set), for OpenCode 1.x and 2.x in all your projects | | `--gemini` | lessloc's entries under `hooks` in `~/.gemini/settings.json`, for all your projects. Gemini CLI's other settings stay as they are | | `--copilot` | the hook file `~/.copilot/hooks/lessloc.json` (`$COPILOT_HOME/hooks/lessloc.json`), for Copilot CLI and VS Code's agent mode in all your projects | | `--kilo` | the bridge plugin `~/.config/kilo/plugin/lessloc.js` (under `$XDG_CONFIG_HOME` when set), for Kilo Code's CLI and VS Code extension in all your projects | | `--amp` | the bridge plugin `~/.config/amp/plugins/lessloc.js` (under `$XDG_CONFIG_HOME` when set), for all your projects | | `--cline` | the bridge plugin `~/.cline/plugins/lessloc.js` (`$CLINE_DIR/plugins/lessloc.js`) for the Cline CLI and SDK, and five hook files in `~/Documents/Cline/Hooks/` for Cline's VS Code extension, for all your projects. If one of those hook files is your own, it writes none and says so | | `--pi` | the extension `~/.pi/agent/extensions/lessloc.js` (`$PI_CODING_AGENT_DIR/extensions/lessloc.js`), for all your projects | | `--qwen` | lessloc's entries under `hooks` in `~/.qwen/settings.json`, for all your projects. Qwen Code's other settings stay as they are | | `--droid` | lessloc's entries in `~/.factory/hooks.json`, or under `hooks` in `~/.factory/settings.json` when Droid keeps your hooks there and the first file does not exist | | `--augment` | lessloc's entries under `hooks` in `~/.augment/settings.json`, for Auggie and Augment's VS Code and IntelliJ extensions in all your projects. They run `augment/hook.sh` from your clone | | `--kiro` | the hook file `~/.kiro/hooks/lessloc.json`, for Kiro's CLI in all your projects | | `--junie` | lessloc's entries under `hooks` in `~/.junie/config.json`, and the working standard in `~/.junie/AGENTS.md` between `` markers | | `--openhands` | lessloc's entries in `~/.openhands/hooks.json` (`$OH_PERSISTENCE_DIR/hooks.json`), which OpenHands reads for repositories without their own `.openhands/hooks.json` | | `--goose` | the plugin folder `~/.agents/plugins/review/`, and the working standard in `~/.config/goose/.goosehints` between `` markers. A plugin of yours named `review` is left alone | | `--devin` | lessloc's entries under `hooks` in `~/.config/devin/config.json` (`%APPDATA%\devin\config.json` on Windows), for all your projects | | `--crush` | lessloc's entry under `hooks` in `~/.config/crush/crush.json` (`$CRUSH_GLOBAL_CONFIG/crush.json`), and the working standard in `~/.config/crush/CRUSH.md` between `` markers | | `--antigravity` | a hook named `review` in `~/.gemini/config/hooks.json`, for Antigravity's app, CLI and IDE in all your projects. A hook of yours named `review` is left alone | | `--aider` | a block in `~/.aider.conf.yml` between `# >>> lessloc` and `# <<< lessloc` lines, with `lint-cmd`, `read` and `git-commit-verify: true`, each only where you set no value; and the folder `~/.config/agent-standard/` with the lint command, `check`, and the working standard, `CONVENTIONS.md` | | `--copilot --repo` | `.github/hooks/review.json` in the repository instead, for Copilot's cloud agent ([Copilot cloud agent](https://lessloc.org/guide/getting-started.md#copilot-cloud-agent)). Prints the setup step that gets lessloc for the cloud agent | Running `init` again is safe. Hook files lessloc wrote are rewritten, its block in AGENTS.md, in a tool's rules file and in `~/.aider.conf.yml` is replaced, and its entries in the agent tools' hook files are replaced while everyone else's are kept. A tool's file keeps its own indentation. A file that has comments or is not a JSON object is left alone, and `init` says so and prints the entries to add by hand. ### remove ```sh lessloc remove [--agents] [--codex] [--cursor] [--opencode] [--kilo] [--amp] [--cline] [--pi] [--copilot] [--gemini] [--qwen] [--droid] [--augment] [--kiro] [--junie] [--openhands] [--goose] [--devin] [--crush] [--antigravity] [--aider] [--repo] ``` In a repository, `remove` undoes `init` for this clone. It deletes: - lessloc's lines in the git hooks, and the hook files that held nothing else - the lessloc block in AGENTS.md, and the whole file when it held only that block - lessloc's own files for this checkout, in your user cache It keeps `.lessloc.json`, `.lessloc-baseline.json`, `.github/workflows/lessloc.yml` and `.github/hooks/review.json`, because they are shared with your team. It also keeps the `@AGENTS.md` line that `init --rules` may have added to CLAUDE.md; delete it by hand if you no longer want it. A tool's flag takes lessloc out of that tool, for all your projects, and `--agents` out of every tool that `init` can set up. Outside a repository, these flags take out only the agent tools' hooks. - A hook or settings file gets back the bytes it had before `init`, when nothing but lessloc's entries changed since. A file that `init` created is deleted. Otherwise everyone else's entries and settings stay, in the file's own indentation. lessloc keeps what each file held in `originals.json` in its user cache ([Files lessloc writes](#in-your-user-cache)). - lessloc's plugin files, Cline hook files, goose's `review` plugin folder and Aider's `agent-standard` folder are deleted. - lessloc's block comes out of a tool's rules file and out of `~/.aider.conf.yml`, and the file goes when it held nothing else. - With `--repo`, `--copilot` and `--agents` take lessloc's entries out of `.github/hooks/review.json`. - A file that has comments or is not a JSON object is left alone. `remove` names it on stderr, says which entries to take out by hand, and exits with 1. - Plugins and extensions a tool installed itself are removed in that tool. `remove` prints how. `remove` leaves the snapshots of the working tree in `.git/objects` ([In your repository](#in-your-repository)). They are unreachable objects that git never pushes, and `git gc` prunes them once they are older than `gc.pruneExpire` (two weeks by default). To delete them now, run `git gc --prune=now` in the repository after every agent session has ended; it also deletes the repository's other unreachable objects, such as a dropped stash. lessloc trims its cache at the start of each agent session. Once you have removed lessloc everywhere, nothing runs that cleanup, so delete `~/.cache/lessloc` by hand, and the clone `~/.lessloc` with it. Do that only after every agent session that was open during the removal has ended, the one that ran `remove` included: an agent tool registers its hooks when a session starts, and the session keeps running them until it ends, so they write lessloc's folder in the cache again. ### catalog ```sh lessloc catalog [--write] ``` Prints the pattern catalog's tables as PATTERNS.md shows them, generated from `lib/catalog.mjs`. `--write` writes them into lessloc's own PATTERNS.md, between its catalog markers. This is for contributors adding a pattern ([ARCHITECTURE.md](https://github.com/rextail-ai/lessloc/blob/main/ARCHITECTURE.md#adding-a-pattern)). ### hosts ```sh lessloc hosts [--markdown] [--write] ``` Prints each agent tool lessloc knows: how to install lessloc for it, and its status. A status says "verified live" only for what was run live in that tool, with the tool's version; otherwise it says "tested against documented payloads". `--markdown` prints it as the table that [Supported agent tools](https://lessloc.org/guide/getting-started.md#supported-agent-tools) shows. `--write` writes that table into lessloc's own getting-started guide, between its hosts markers. The rows live in `TOOLS` in `lib/install.mjs` ([ARCHITECTURE.md](https://github.com/rextail-ai/lessloc/blob/main/ARCHITECTURE.md#adding-an-agent-tool)). ### version ```sh lessloc version ``` Prints lessloc's version. ### Used by the git hooks You do not run these yourself. The hooks that `init` installs do. - **`check --staged --gate`** (pre-commit) reviews the staged state. When tier-0 findings remain, it prints "Not committed. Fix these first:" with up to ten plain lines, and the commit is refused ([The commit gate](https://lessloc.org/guide/ci.md#the-commit-gate)). - **`trailers []`** (prepare-commit-msg) adds the commit trailers that the [`trailers`](https://lessloc.org/guide/configuration.md#trailers) setting asks for ([Trailers](https://lessloc.org/guide/ci.md#trailers)). ## Finding kinds Every finding has a kind and a tier. - **Tier 0** fails `check` and blocks the commit gate, for the kinds `check` reports. `tests-failed`, `policy` and `protected` come only from an agent session, where they hold the turn: `check`, the commit gate and CI never report them. - **Tier 1** marks risks. - **Tier 2** covers duplication, structure and brute force. - **Tier 3** covers knowledge: comments and docs. - **Tier 4** is size. **Stops** marks the kinds in the default [`interrupt`](https://lessloc.org/guide/configuration.md#interrupt) list. In a session, they hold the agent's turn once, with what is unfinished. Findings of other kinds do not interrupt the agent: they are kept for the next session's handoff, and `check` and CI report them. Some kinds come only from an agent session, because they need its request, its test runs or its last reply: `tests-failed`, `policy`, `protected`, `unaddressed`, `unfinished`, `unrequested-file`, `unrequested-doc` and `refactor-tests`. Some come only from `check`: `config`, `ratchet` (with `--ratchet`), and `unverified` at tier 0 (with `--require-evidence`). How often kinds fired on commits people merged is on the [Benchmarks](https://lessloc.org/benchmarks.md#precision-on-code-humans-merged-without-a-model-bench-replay-mjs) page. Suppress a false positive with a reason, or accept it as the owner ([Configuration](https://lessloc.org/guide/configuration.md#suppressing-one-finding-in-the-code)). Each pattern page shows an example and the standard behind it; [Patterns](https://lessloc.org/patterns/index.md) lists them all. Copies are found in three forms: - **Blocks:** repeated runs of lines. - **Renamed copies:** a function with the same body under other names, where only local variables differ. A function that calls different things counts as different code. - **Rule literals:** a format, strftime pattern, regex or query copied out of the function that owns it, or one new rule written in two places. Duplicate findings show how widely each copy is used, so "same responsibility?" comes with evidence. ### Tier 0 | Kind | Stops | What it reports | Pattern | |---|---|---|---| | `tests-failed` | yes | A test run on the final state failed. | [Done without a passing run](https://lessloc.org/patterns/index.md#unverified) | | `ratchet` | | A codebase measure got worse than its line: more duplication, copies, similar names, unreferenced definitions, comments, commented-out code, swallowed errors, pass-through wrappers, junk files or instruction tokens, or fewer test assertions. Tier 0 only against a baseline this version recorded. | [Ratchet](https://lessloc.org/patterns/index.md#ratchet) | | `config` | | `.lessloc.json` is not valid JSON, or has an unknown key, a wrong type or an exception without a reason. It never refuses a commit. | [Broken settings](https://lessloc.org/patterns/index.md#config) | | `policy` | yes | `.lessloc.json` or `.lessloc-baseline.json` changed in an agent session without your approval. Only in a session: `check`, the commit gate and CI do not report it. | [Policy edits](https://lessloc.org/patterns/index.md#policy-edits) | | `protected` | yes | A path in `protect` changed in an agent session without your approval. Only in a session: `check`, the commit gate and CI do not report it. | [Policy edits](https://lessloc.org/patterns/index.md#policy-edits) | | `boundary` | yes | An added import breaks a rule in `boundaries`. | [Boundary](https://lessloc.org/patterns/index.md#boundary) | | `dependency` | yes | An added dependency is not in `approvedDependencies`. | [Approved dependencies](https://lessloc.org/patterns/index.md#approved-dependencies) | | `secret` | yes | A credential in a known format in the added lines, or a new `.env` file that would be committed. | [Secrets](https://lessloc.org/patterns/secrets.md) | | `placeholder` | yes | A placeholder such as `# ... existing code ...`. Refused before it lands. | [Placeholders](https://lessloc.org/patterns/placeholders.md) | | `commented-out` | yes | Commented-out code. Refused before it lands. | [Commented-out code](https://lessloc.org/patterns/commented-out.md) | | `junk-file` | yes | A new backup, copy, scratch or report file (refused before it lands, unless the request names it), or cache files, build output or logs that would be committed. | [Scratch files](https://lessloc.org/patterns/index.md#scratch-files), [report files](https://lessloc.org/patterns/index.md#report-files), [caches](https://lessloc.org/patterns/index.md#caches), [versions side by side](https://lessloc.org/patterns/side-by-side.md) | | `weakened-tests` | yes | Test assertions or tests removed, or tests newly skipped, focused or forced to pass. Tier 0 in a session; `check`, where a person may remove a test on purpose, reports it at tier 1. | [Weakened tests](https://lessloc.org/patterns/weakened-tests.md) | ### Tier 1 | Kind | Stops | What it reports | Pattern | |---|---|---|---| | `swallowed-error` | yes | An error caught and dropped, such as `except Exception: pass` or an empty catch. The agent hears about it right after the write. | [Swallowed errors](https://lessloc.org/patterns/swallowed-error.md) | | `suppression` | yes | A blanket suppression: `@ts-ignore`, `any`, `# type: ignore` or `# noqa` without a code, `eslint-disable` without a rule, `nolint` without a linter and a reason, `allow(dead_code)`. | [Silenced checks](https://lessloc.org/patterns/silenced-checks.md) | | `unsafe` | yes | A shortcut that trades away safety: SQL built from values, HTML built or escaped by hand, `Math.random()` for secrets, a fast hash for passwords, certificate checks turned off, text run as code. | [SQL](https://lessloc.org/patterns/sql-from-values.md), [HTML](https://lessloc.org/patterns/html-by-hand.md), [randomness](https://lessloc.org/patterns/weak-randomness.md), [password hashes](https://lessloc.org/patterns/password-hash.md), [TLS](https://lessloc.org/patterns/tls-off.md), [eval](https://lessloc.org/patterns/eval.md) | | `made-up-api` | yes | Another language's API, such as `null` in Python, `len()` in JavaScript or `None` in Go, or an import that no manifest declares and the platform does not provide. | [Another language's API](https://lessloc.org/patterns/wrong-language-api.md), [undeclared imports](https://lessloc.org/patterns/undeclared-import.md) | | `test-logic` | yes | Production code that checks whether tests are running, says it exists to make tests pass, or branches on a value that appears only in tests. | [Test logic](https://lessloc.org/patterns/test-logic.md), [test-shaped code](https://lessloc.org/patterns/test-shaped.md) | | `stub` | yes | A stub or fake success left in, such as `raise NotImplementedError`, `todo!()` or a "not implemented" error, or mock data in production code. Judged when the turn ends. | [Stubs](https://lessloc.org/patterns/stubs.md) | | `debug-leftover` | yes | `debugger`, `breakpoint()`, `pdb.set_trace()`, or a debug print such as `print("here")`. Judged when the turn ends. | [Debug leftovers](https://lessloc.org/patterns/debug-leftovers.md) | | `empty-test` | yes | A test that asserts nothing or only a constant, or one that mocks the code it tests. | [Empty tests](https://lessloc.org/patterns/empty-test.md), [mocked subject](https://lessloc.org/patterns/mocked-subject.md) | | `untested` | yes | The change adds behavior and no test changed, [with some exceptions](https://lessloc.org/guide/how-it-works.md#end-of-turn-unfinished-work-is-held-once). | [Untested](https://lessloc.org/patterns/untested.md) | | `unaddressed` | yes | A function, option or flag the request asked for appears nowhere in the repository. A name the request only mentions does not count. | [Stopping short](https://lessloc.org/patterns/unaddressed.md) | | `unfinished` | yes | In a turn that changed code, the agent's last reply says part of the reported problem still fails, or that the same problem may be elsewhere, unchecked. | [Leaving part of the problem failing](https://lessloc.org/patterns/unfinished.md) | | `removed-safeguard` | | Logging, error handling, validation, or a comment that states a security rule or contract was removed. | [Removed safeguards](https://lessloc.org/patterns/removed-safeguard.md) | | `copy` | yes | A block of `dupLines` code lines that also exists elsewhere, or a function with the same body as another under other names. | [Reimplemented helpers](https://lessloc.org/patterns/reuse-helper.md), [duplicate shapes](https://lessloc.org/patterns/duplicate-shapes.md) | | `versioned-copy` | yes | A definition beside an older version of itself, such as `parse_date_v2` next to `parse_date`. | [Versions side by side](https://lessloc.org/patterns/side-by-side.md) | | `unverified` | yes | In a session, the turn changed what the tests run, so a pass no longer shows the code works. With `check --require-evidence`, no passing test run is recorded for exactly the state checked, at tier 0. | [Done without a passing run](https://lessloc.org/patterns/index.md#unverified) | ### Tier 2 | Kind | Stops | What it reports | Pattern | |---|---|---|---| | `simpler` | | Brute-force code with a shorter idiom: `== True`, an if/else that returns the condition, an index loop that only reads `xs[i]`, `.filter(...).length > 0`, a catch that only rethrows, a flag loop that is `any()`, a hand-built counter, an `__init__` that `@dataclass` writes, a React Effect that only sets state. ruff and clippy add more. | [Brute force](https://lessloc.org/patterns/brute-force.md), [counters and flags](https://lessloc.org/patterns/counters-and-flags.md), [catch and rethrow](https://lessloc.org/patterns/catch-rethrow.md), [promise wrapping](https://lessloc.org/patterns/promise-wrap.md), [inferred types](https://lessloc.org/patterns/inferred-types.md), [derived Effects](https://lessloc.org/patterns/derived-effect.md), [boilerplate](https://lessloc.org/patterns/boilerplate.md), [rebuilt library](https://lessloc.org/patterns/rebuilt-library.md) | | `reinvented` | | A hand-written version of what the standard library or an installed package does (deep clone, UUID, debounce, slugify, CSV, flag parsing), or a retry loop where the project already retries. | [Rebuilt library](https://lessloc.org/patterns/rebuilt-library.md) | | `defensive` | | A check for a case that cannot happen, such as testing a value just created for emptiness. | [Defensive code](https://lessloc.org/patterns/defensive.md) | | `unused` | | A new function or import that nothing calls, imports or tests. | [Unused](https://lessloc.org/patterns/unused.md) | | `single-use` | | A one-line helper used once. | [Single use](https://lessloc.org/patterns/single-use.md) | | `near-copy` | | A new function that is mostly the same code as an existing one. | [Reimplemented helpers](https://lessloc.org/patterns/reuse-helper.md) | | `repeated-literal` | | A rule, such as a format, regex, query or date pattern, copied out of the function that owns it, or one new rule written in several places. | [Copied rules](https://lessloc.org/patterns/copied-rule.md) | | `style-copy` | | The same CSS declarations under another selector, or the same long utility-class string written again and again. | [CSS copies](https://lessloc.org/patterns/css-copy.md) | | `similar-name` | | A new function whose name looks like an existing one's. | [Reimplemented helpers](https://lessloc.org/patterns/reuse-helper.md) | | `wrapper` | | A function that only forwards to another, or a file that only re-exports. | [Wrappers](https://lessloc.org/patterns/wrappers.md) | | `single-impl` | | An interface or abstract class with one implementation, or an abstract class with none. | [Single implementation](https://lessloc.org/patterns/single-impl.md) | | `if-chain` | | Three or more if-branches on literal values of one variable, where a lookup table would do. | [If-chains](https://lessloc.org/patterns/if-chain.md) | | `mode-flag` | | An existing function gained a boolean flag parameter. | [Speculative options](https://lessloc.org/patterns/speculative-options.md) | | `long-params` | | A new function with 8 or more parameters. | [Long parameter lists](https://lessloc.org/patterns/long-params.md) | | `deep-nesting` | | Statements nested 4 or more control levels deep. | [Deep nesting](https://lessloc.org/patterns/deep-nesting.md) | | `long-function` | | A function over 100 code lines, 20 or more of them written by the change. | [Long functions](https://lessloc.org/patterns/long-function.md) | | `clever` | | Golfed code: nested ternaries, nested conditional expressions, comprehensions with several `for` clauses. | [Golfed code](https://lessloc.org/patterns/golfed-code.md) | | `copied-tests` | | Copy-pasted test cases that one table-driven test would cover. | [Copied tests](https://lessloc.org/patterns/copied-tests.md) | | `demo-code` | | An example or demo block, such as `if __name__ == "__main__":`, in a module other code imports. | [Demo code](https://lessloc.org/patterns/demo-code.md) | | `unrequested-file` | | A deployment, container or CI file nobody asked for. | [Scaffolding](https://lessloc.org/patterns/scaffolding.md) | | `tiny-file` | | A new code file under 10 code lines. | [Tiny files](https://lessloc.org/patterns/tiny-files.md) | | `new-folder` | | A new folder with one or two entries, or an examples or demo folder nobody asked for. | [One-file folders](https://lessloc.org/patterns/one-file-folders.md), [demo code](https://lessloc.org/patterns/demo-code.md) | | `swollen-file` | | A file that grew by 150 or more lines and now has over 800 code lines. | [Swollen files](https://lessloc.org/patterns/swollen-file.md) | | `vague-name` | | A new name made only of vague words, such as `process_data` or `handle`. | [Vague names](https://lessloc.org/patterns/vague-name.md) | | `junk-drawer` | | A new `utils`, `helpers`, `common`, `misc` or similar module or folder. | [Junk drawers](https://lessloc.org/patterns/junk-drawer.md) | | `moved-files` | | Files moved or renamed when the request did not ask for a refactor. | [Moved files](https://lessloc.org/patterns/moved-files.md) | | `new-dependency` | | A new dependency, when there is no `approvedDependencies` list. It says when an installed one already does the job, or when the lockfile does not pin it. | [Second library](https://lessloc.org/patterns/second-library.md) | | `refactor-tests` | | A refactor changed existing tests. | [Refactor tests](https://lessloc.org/patterns/refactor-tests.md) | | `churn` | | Lines only reformatted (quotes, spacing, punctuation) that the task did not need to change. | [Churn](https://lessloc.org/patterns/churn.md) | ### Tier 3 | Kind | Stops | What it reports | Pattern | |---|---|---|---| | `narration` | | Comments that narrate the code, decorate it or restate a name, closing-brace comments, and log lines that narrate each step. | [Narration](https://lessloc.org/patterns/narration.md), [decoration](https://lessloc.org/patterns/decoration.md), [restating docs](https://lessloc.org/patterns/restating-docs.md), [log chatter](https://lessloc.org/patterns/log-chatter.md) | | `history` | | Dates, change history or notes to the requester in comments. | [Tombstones](https://lessloc.org/patterns/tombstones.md), [history](https://lessloc.org/patterns/history.md) | | `todo` | | A TODO, FIXME, HACK or XXX comment left in. | [TODO and FIXME](https://lessloc.org/patterns/todo.md) | | `comment-density` | | Comments make up more than `maxCommentRatio` of a file's added lines. | [Comment weight](https://lessloc.org/patterns/comment-weight.md) | | `comment-block` | | A comment block of 6 or more lines. | [Comment weight](https://lessloc.org/patterns/comment-weight.md) | | `dense-lines` | | Code lines over 160 characters, long strings and URLs aside. | [Golfed code](https://lessloc.org/patterns/golfed-code.md) | | `stale-doc` | | A doc that still names something the change removed or renamed. | [Stale docs](https://lessloc.org/patterns/stale-doc.md) | | `doc-removed` | | More than 20 lines of documentation removed. | [Docs removed](https://lessloc.org/patterns/doc-removed.md) | | `unrequested-doc` | | A doc file nobody asked for, or a doc that grew by 40 or more lines without a request for docs. | [Unrequested docs](https://lessloc.org/patterns/unrequested-docs.md) | | `long-instructions` | | A directory tour in always-loaded instructions, or instruction files over `instructionBudget` tokens. | [Long instructions](https://lessloc.org/patterns/long-instructions.md) | ### Tier 4 | Kind | Stops | What it reports | Pattern | |---|---|---|---| | `net-growth` | | The change adds more than `maxNetLines` net lines. | [Net growth](https://lessloc.org/patterns/net-growth.md) | | `new-files` | | The change adds more than `maxNewFiles` new code files. | [Tiny files](https://lessloc.org/patterns/tiny-files.md) | ## Files lessloc writes lessloc writes nothing into your working tree unless you ask. Its own state lives in your user cache. ### In your repository | File | Written by | Notes | Shared with your team? | |---|---|---|---| | `.git/hooks/pre-commit`, `.git/hooks/prepare-commit-msg` | `init` | only hook files that did not exist or held only lessloc's lines | No: git never pushes `.git/hooks` | | `.git/objects` | the agent hooks, the git hooks, `check`, `test`, `verify` and `probe` | snapshots of the working tree, untracked files that git does not ignore included, so a review sees exactly what each tool call changed. Ordinary unreachable objects, which `git gc` prunes once they are older than `gc.pruneExpire` (two weeks by default). Untracked files over 1 MB are skipped | No: git never pushes them | | `.git/worktrees/` | `verify`, `ci`, `probe`, `check --ratchet` | git's record of a temporary clean checkout, removed with it | No | | `AGENTS.md` | `init --rules` | the working standard, between `` markers | Only when you commit it | | `CLAUDE.md` | `init --rules` | one `@AGENTS.md` line, only when CLAUDE.md exists and does not import AGENTS.md | Only when you commit it | | `.lessloc.json` | `init --config` | the owner's settings ([Configuration](https://lessloc.org/guide/configuration.md)) | Only when you commit it | | `.lessloc-baseline.json` | `init --baseline`, `baseline` | the ratchet's line | Only when you commit it | | `.github/workflows/lessloc.yml` | `init --ci` | the CI workflow | Only when you commit it | | `.github/hooks/review.json` | `init --copilot --repo` | Copilot's hooks for its cloud agent; each line does nothing where node or `~/.agent-standard` is missing | Only when you commit it | | commit messages | the `prepare-commit-msg` hook | `Lessloc-Verified`, and with `"all"` also `Lessloc-Open` and `Lessloc-Unchecked`, only when `trailers` is set | Yes, in history | ### In your user cache lessloc keeps one folder per checkout, and per git worktree: `~/.cache/lessloc/-/`. `` is the checkout's folder name, and `` comes from the path of its git directory. `LESSLOC_STATE` replaces the whole root. Otherwise `XDG_CACHE_HOME`, or `LOCALAPPDATA` on Windows, replaces `~/.cache`. | File | What it holds | |---|---| | `repo` | the git directory this folder belongs to | | `sessions/.json` | a session's state: where its turn started, its request, its last test run, whether it was held | | `sessions/.log` | a session's journal: the files each agent touched, approvals asked, notes given, turns held | | other files in `sessions/` | private git indexes for snapshots, and the snapshots taken around tool calls and subagents | | `evidence/.json` | the test result for one exact tree | | `ledger/.json` | the handoff record a session leaves: its request, files, net lines, verification, what was left open, and its last message | | `defs-*.json` | the definitions index, cached per file | | `staged.json` | the last pre-commit check, reused for the commit's trailers | | `python-stdlib.txt` | the names of Python's standard library modules, asked of `python3` once, for the check of undeclared imports | | `cost.log` | each hook's time and tokens added, and the agent tool, when it is not Claude Code | | `once/` | one empty file per recent Codex, Cursor, Copilot, Gemini CLI or Qwen Code hook payload, kept 30 seconds, so a tool that runs lessloc's hooks twice for one event (a plugin's and a hook file's) gets one answer | | `errors.log` | hook errors, shown by `doctor` | Beside the checkout folders, `originals.json` keeps what each agent tool's hook or settings file held before `init` first added lessloc's entries: its text, where writing it back would not give the same bytes, or that `init` created it. `remove` uses it to give each file back exactly. Only your user can read it. lessloc restricts no other file. It creates every other folder and file here with your account's default permissions, so on a machine you share with other accounts, they can read these files, the shortened prompts among them, wherever your home folder lets them. To make them private, run `chmod 700 ~/.cache/lessloc`, or the same on `$XDG_CACHE_HOME/lessloc` or the `LESSLOC_STATE` folder when one of them is set. What lessloc writes inside it later stays private too. Session files go after a week, and handoff records after two weeks. Evidence keeps the 50 newest trees, and the logs keep their recent lines. A checkout's whole folder is removed when the checkout is gone, or a month after lessloc first wrote it. None of these files is shared with your team: they live in your account's cache, outside the repository ([several agents in one checkout](https://lessloc.org/guide/how-it-works.md#several-agents-in-one-checkout)). Session state and handoff records keep a shortened copy of your prompts, and handoff records also keep the agent's last message, so the next session can be told what was asked and how it ended. ### In your home folder | File | Written by | |---|---| | `~/.codex/hooks.json`, or `$CODEX_HOME/hooks.json` | `init --codex` | | `~/.cursor/hooks.json` | `init --cursor` | | `~/.config/opencode/plugins/lessloc.js`, or the same under `$XDG_CONFIG_HOME` | `init --opencode` | | `~/.gemini/settings.json`, the `hooks` key | `init --gemini` | | `~/.copilot/hooks/lessloc.json`, or `$COPILOT_HOME/hooks/lessloc.json` | `init --copilot` | | `~/.config/kilo/plugin/lessloc.js`, or the same under `$XDG_CONFIG_HOME` | `init --kilo` | | `~/.config/amp/plugins/lessloc.js`, or the same under `$XDG_CONFIG_HOME` | `init --amp` | | `~/.cline/plugins/lessloc.js`, or `$CLINE_DIR/plugins/lessloc.js` | `init --cline` | | `~/Documents/Cline/Hooks/`: `TaskStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `TaskComplete` | `init --cline` | | `~/.pi/agent/extensions/lessloc.js`, or `$PI_CODING_AGENT_DIR/extensions/lessloc.js` | `init --pi` | | `~/.qwen/settings.json`, the `hooks` key | `init --qwen` | | `~/.factory/hooks.json`, or the `hooks` key of `~/.factory/settings.json` | `init --droid` | | `~/.augment/settings.json`, the `hooks` key | `init --augment` | | `~/.kiro/hooks/lessloc.json` | `init --kiro` | | `~/.junie/config.json`, the `hooks` key, and a block in `~/.junie/AGENTS.md` | `init --junie` | | `~/.openhands/hooks.json`, or `$OH_PERSISTENCE_DIR/hooks.json` | `init --openhands` | | `~/.agents/plugins/review/`: `plugin.json`, `hooks/hooks.json`, and a block in `~/.config/goose/.goosehints` | `init --goose` | | `~/.config/devin/config.json` (`%APPDATA%\devin\config.json` on Windows), the `hooks` key | `init --devin` | | `~/.config/crush/crush.json`, or `$CRUSH_GLOBAL_CONFIG/crush.json`, the `hooks` key, and a block in `~/.config/crush/CRUSH.md` | `init --crush` | | `~/.gemini/config/hooks.json`, the hook named `review` | `init --antigravity` | | `~/.aider.conf.yml`, a block, and `~/.config/agent-standard/`: `check`, `CONVENTIONS.md` | `init --aider` | lessloc changes only its own entries in these files, which are not shared with your team. Claude Code installs and removes the Claude Code plugin itself. ### Temporary files In the system's temporary folder, lessloc creates clean checkouts (`lessloc-checkout-*`) for `verify`, `ci`, `probe` and `check --ratchet`, a folder (`lessloc-doctor-*`) where `doctor` runs each installed hook command once, and short-lived input and index files. Each is removed when its command ends. ## Environment variables | Variable | Effect | |---|---| | `LESSLOC=off` | turns every agent hook off ([Configuration](https://lessloc.org/guide/configuration.md#turning-lessloc-off)) | | `LESSLOC_OFF` | turns single components off, comma-separated ([Configuration](https://lessloc.org/guide/configuration.md#lessloc-off-single-components)) | | `LESSLOC_STATE` | the folder lessloc keeps its files in, instead of the user cache | | `LESSLOC_DEBUG=1` | prints hook errors on the hook's stderr as they happen; `doctor` shows the logged ones | | `LESSLOC_PROFILE=1` | prints each detector's time | | `LESSLOC_SOURCE` | where the workflow from `init --ci` installs lessloc from, instead of `github:rextail-ai/lessloc#v` | | `XDG_CACHE_HOME`, `LOCALAPPDATA` | the user cache that lessloc's folder goes in | | `XDG_CONFIG_HOME` | where `init --opencode`, `--kilo` and `--amp` put their plugin files, `init --goose` and `--crush` their rules blocks, `init --crush` its hook entry and `init --aider` its `agent-standard` folder, and where `init` and `doctor` look for the settings folders of OpenCode, Amp, Kilo Code, goose, Crush and Zed | | `CRUSH_GLOBAL_CONFIG` | the folder of Crush's `crush.json`, as Crush reads it, for `init --crush` | | `OH_PERSISTENCE_DIR` | where `init --openhands` writes OpenHands' hook file, as OpenHands reads it | | `CLINE_DIR` | where `init --cline` puts the Cline plugin, and where `init` and `doctor` look for Cline's folder | | `CODEX_HOME` | where `init --codex` finds Codex's `hooks.json`, and `init` and `doctor` find Codex's `config.toml` | | `COPILOT_HOME` | where `init --copilot` writes Copilot's hook file, and where `init` and `doctor` find Copilot's installed plugins | | `CLAUDE_CONFIG_DIR` | where `init` and `doctor` look for Claude Code's installed plugins | | `PI_CODING_AGENT_DIR` | where `init --pi` puts the Pi extension, and where `init` and `doctor` look for Pi's folder and its installed packages | | `CLAUDECODE=1`, `CODEX_THREAD_ID`, `CURSOR_AGENT=1`, `OPENCODE_TERMINAL=1`, `GEMINI_CLI=1`, `COPILOT_CLI=1`, `KILO_PID`, `CLINE_ACTIVE=true`, `PI_CODING_AGENT=true`, `QWEN_CODE=1`, `AUGMENT_AGENT=1`, `GOOSE_TERMINAL=1`, `CRUSH=1` | set by Claude Code, Codex, Cursor, OpenCode 2.x, Gemini CLI, Copilot CLI, Kilo Code, Cline's VS Code terminals, Pi, Qwen Code, Augment, goose and Crush for the commands their agents run; `init` reads them to name the tool it runs in. Amp, Factory Droid, Kiro, Junie, OpenHands, Devin CLI, Antigravity and Aider document none | | `CURSOR_VERSION`, `CURSOR_PROJECT_DIR`, `PLUGIN_DATA` | set by the agent tools themselves; lessloc reads them to tell which tool ran a hook that does not name its host | | `DEVIN_PROJECT_DIR` | set by Devin CLI for its hooks; lessloc reads it as the project folder, since Devin's hook payloads do not name it | | `GITHUB_ACTIONS=true` | `check` prints GitHub annotations | | `GITHUB_BASE_REF` | the branch `ci` compares with, as `origin/` plus its value, when no `--base` is given | --- # Writing good functions with AI agents An AI coding agent can make a task pass and still leave the code harder to change. What matters for the next task is the shape the code is left in: how it is split into functions, how deep it nests, how many special cases pile up in one place. The next person, or the next agent session, pays for that shape. This page lists shapes to avoid and shapes to prefer, each with a short Python example. Then it shows what lessloc does about it. The ideas hold in any language. ## Shapes to avoid ### Brute force and copy-paste Brute force is writing by hand what the language already has: ```python late = False for order in orders: if order.status == "late": late = True ``` `late = any(order.status == "late" for order in orders)` says the same in one line. Copy-paste is the same rule written again under new names. Here, in `shop/billing.py`: ```python def invoice_total(items): subtotal = sum(item.price * item.qty for item in items) fee = max(0.30, subtotal * 0.029) return round(subtotal + fee, 2) def quote_total(lines): amount = sum(line.price * line.qty for line in lines) fee = max(0.30, amount * 0.029) return round(amount + fee, 2) ``` When the fee changes, someone updates one of them, and quotes stop matching invoices. Agents rarely reuse: a new session does not know what earlier sessions wrote, and writing a function is one step while finding the old one takes a search. The fix is to call the function that exists, or to give the rule one home that both call. ### Accretion of special-case branches Each task adds one more branch. No single change looks wrong, and a year later the function is a list of exceptions. ```python def shipping_cost(order): if order.country == "US": return 5 elif order.country == "CA": return 8 elif order.country == "MX": return 9 elif order.country == "DE" and order.total > 100: return 0 elif order.country == "DE": return 12 return 15 ``` For one task, the smallest change that passes is often one more `elif`. See [data instead of growing branches](#data-instead-of-growing-branches). ### Arrow code Nested conditions push the real work to the right edge, in the shape of an arrowhead. To know when a line runs, a reader must keep every condition above it in mind. ```python def apply_coupon(order, code): if order is not None: if code: coupon = COUPONS.get(code) if coupon: if not coupon.expired: if order.total >= coupon.minimum: order.total -= coupon.amount return True return False ``` See [guard clauses](#guard-clauses). ### God functions One function parses the input, applies the business rules, writes to the database and sends the email. Any change to any of these touches it, and testing one rule means setting up all of it. ```python def checkout(request): data = json.loads(request.body) if "items" not in data: return {"error": "no items"}, 400 total = 0 for item in data["items"]: product = db.get_product(item["id"]) total += product.price * item["qty"] if data.get("coupon") == "SPRING": total *= 0.9 order_id = db.insert_order(data["user"], total) send_email(data["user"], f"Order {order_id}: {total:.2f}") return {"id": order_id, "total": total}, 200 ``` Real ones run to hundreds of lines. They grow because each task adds its piece where the data already is. ### Hidden coupling Two pieces of code depend on each other through something neither signature shows: a module variable, the order of calls, or an argument changed in place. ```python _currency = "USD" def set_currency(code): global _currency _currency = code def format_price(amount): return f"{amount:.2f} {_currency}" # depends on who called set_currency last def add_tax(order): order.total *= 1.2 # the caller's order changes too return order.total ``` Whoever reads `format_price` alone, person or agent, cannot see the link, and a change somewhere else can break it. ### Premature generality Options, flags, base classes and settings for cases nobody asked for. Each one is code to read, test and keep working. Anthropic's prompting guide says recent Claude models "have a tendency to overengineer by creating extra files, adding unnecessary abstractions, or building in flexibility that wasn't requested." ```python from abc import ABC, abstractmethod class Exporter(ABC): @abstractmethod def export(self, rows, delimiter=",", quote_all=False): ... class CsvExporter(Exporter): # the only exporter there is def export(self, rows, delimiter=",", quote_all=False): ... ``` A boolean parameter that switches behavior is the small form of the same thing. It is two functions in one, and every caller must know which mode it wants: ```python def total(items, include_tax=False): subtotal = sum(i.price for i in items) return subtotal * 1.2 if include_tax else subtotal ``` ### Clever density Code golfed into one line is shorter, but slower to read and easier to get wrong. ```python label = "high" if score > 80 else "mid" if score > 50 else "low" cells = [cell for row in grid for cell in row if cell] ``` The Google Python Style Guide puts it plainly: "Optimize for readability, not conciseness." An `if` statement or a plain loop is easier to read and to change. ## Shapes to prefer ### One job per function, at one level of detail ```python def checkout(request): order = parse_order(request.body) total = price(order.items, order.coupon) order_id = save_order(order, total) send_receipt(order.user, order_id, total) return {"id": order_id, "total": total}, 200 ``` Each line is one step, and all steps sit at the same level. How each step works lives one level down, where a change to pricing does not touch the email. Split by job, not by line count: a one-line helper used once only moves the line somewhere else. ### A pure core with I/O at the edges ```python def late_fee(amount, days_late): if days_late <= 0: return 0 return min(amount * 0.01 * days_late, amount * 0.2) def charge_late_fees(db, today): for invoice in db.unpaid_invoices(): fee = late_fee(invoice.amount, (today - invoice.due).days) db.add_fee(invoice.id, fee) ``` `late_fee` needs no database, clock or network, so its test is one line: `assert late_fee(100, 30) == 20`. `charge_late_fees` only reads, calls and writes. When the fee rule changes, one small function changes, and its tests stay fast. This shape is sometimes called "functional core, imperative shell". ### Data instead of growing branches ```python SHIPPING = {"US": 5, "CA": 8, "MX": 9, "DE": 12} FREE_OVER = {"DE": 100} def shipping_cost(order): if order.total > FREE_OVER.get(order.country, float("inf")): return 0 return SHIPPING.get(order.country, 15) ``` This does what the chain above does. A new country is one entry instead of one more branch, and the free-shipping rule is written once. ### Guard clauses ```python def apply_coupon(order, code): coupon = COUPONS.get(code) if order is None or coupon is None or coupon.expired: return False if order.total < coupon.minimum: return False order.total -= coupon.amount return True ``` The same checks as the arrow above, flat. Each guard handles one way out and returns early, and the main path reads straight down. Fowler's *Refactoring* calls this Replace Nested Conditional with Guard Clauses. ### Generalize at the third case ```python # The first two cases, written out. Two cases do not yet show what will vary. def vip_price(total): return total * 0.8 def staff_price(total): return total * 0.7 # The third case shows the pattern: one rule, and a table of rates. RATES = {"vip": 0.8, "staff": 0.7, "student": 0.9} def group_price(total, group): return total * RATES[group] ``` Fowler's *Refactoring* calls this the rule of three. An abstraction built at the first or second case is a guess about what will vary, and a wrong guess is expensive. As Sandi Metz put it, "duplication is far cheaper than the wrong abstraction". Once the shared version exists, delete the functions it replaces. ### Deep modules In *A Philosophy of Software Design*, John Ousterhout calls a module deep when a small interface hides a lot of work, and shallow when it hides little and makes every caller know the steps. ```python from decimal import ROUND_HALF_UP, Decimal # Shallow: every caller must chain the steps, in the right order. def to_decimal(value): return Decimal(str(value)) def round_cents(amount): return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) text = f"{round_cents(to_decimal(total)):,}" ``` ```python # Deep: one call, and the rounding rule lives in one place. def money(value): """An amount as invoices show it, rounded half up: 1234.505 becomes 1,234.51.""" cents = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return f"{cents:,}" money(2.675) # '2.68' round(2.675, 2) # 2.67: the float 2.675 is stored just below 2.675 ``` Callers of `money` never need to know why plain `round` gets that case wrong. ### Names for concepts ```python # The reader decodes the rule at every place it appears: if order.country in ("DE", "FR", "NL") and order.total > 100 and not order.gift_card: ... # The rule has a name and one home: def ships_free(order): if order.gift_card: return False return order.country in FREE_COUNTRIES and order.total > FREE_SHIPPING_MINIMUM ``` A name earns a function when it says something the code cannot say by itself: `ships_free` names a business rule. A function that only repeats its one line, such as `def get_total(order): return order.total`, adds a jump and nothing else. Vague names such as `process_data`, `handle` or a `utils` module name nothing at all. ### Reshape first, then change Kent Beck's advice: "for each desired change, make the change easy (warning: this may be hard), then make the easy change." ```python # Before: the tax rate is written in two places. def invoice_total(items): return sum(i.price * 1.20 for i in items) def receipt_lines(items): return [f"{i.name}: {i.price * 1.20:.2f}" for i in items] ``` The task: books are taxed at 5%. The quick fix adds a book check in both places. Instead, reshape first, with no change in behavior, so the existing tests pass unchanged: ```python def gross(item): return item.price * 1.20 def invoice_total(items): return sum(gross(i) for i in items) def receipt_lines(items): return [f"{i.name}: {gross(i):.2f}" for i in items] ``` Then the change touches one function: ```python TAX_FACTOR = {"book": 1.05} def gross(item): return item.price * TAX_FACTOR.get(item.kind, 1.20) ``` Two small steps are easier to review than one tangled one. You can ask an agent for exactly this: "First move the tax rule into one function, with no behavior change, and run the tests. Then add the book rate, with a test." ## What lessloc does about it The working standard every session starts with ([the whole text](https://lessloc.org/guide/how-it-works.md#session-start)) covers some of these shapes: take what the codebase, the standard library or an installed dependency already has before writing new code; one implementation per rule; no wrapper, option, flag, config, interface or file nobody asked for; delete what the change makes dead. When the agent says it is done, lessloc reviews what it wrote. These are the findings about function shape: | Kind | What it finds | During the session | |---|---|---| | [`copy`](https://lessloc.org/patterns/reuse-helper.md) | A new function whose body matches an existing one except for local names, or a block of 6 or more lines (`dupLines`) that the change mostly wrote and that also exists elsewhere | Holds the turn, once | | [`if-chain`](https://lessloc.org/patterns/if-chain.md) | 3 or more `if`/`elif` branches comparing one variable with a literal value, at least one of them new | Note | | [`deep-nesting`](https://lessloc.org/patterns/deep-nesting.md) | A new control statement inside 3 or more other control blocks | Note | | [`long-function`](https://lessloc.org/patterns/long-function.md) | A function over 100 code lines, at least 20 of them written by the change | Note | | [`clever`](https://lessloc.org/patterns/golfed-code.md) | In Python, a nested conditional expression or a comprehension with several `for` clauses; in JavaScript, TypeScript, Java, C#, C and C++ and others, a nested ternary | Note | | [`mode-flag`](https://lessloc.org/patterns/speculative-options.md) | An existing function that gains a boolean parameter | Note | | [`single-use`](https://lessloc.org/patterns/single-use.md) | A new one-line helper used once | Note | On this page's examples, the copy-paste pair is a `copy`, the shipping chain an `if-chain`, the coupon arrow `deep-nesting`, both golfed lines `clever`, and `total` gaining `include_tax` a `mode-flag`. The base class with its single subclass is a `single-impl` note. For the copy, the agent reads one line like this in the turn's hold, before it may finish: ```text - shop/billing.py:7: `quote_total` has the same body as `invoice_total` (shop/billing.py:1, used in 2 file(s)) with names changed: call or merge into one ``` These notes never reach the agent during the turn. They go into the session's handoff record: if the work is still uncommitted when the next session starts, that session reads up to five open items in plain words. `lessloc check` shows them, and in CI each one becomes a notice on the pull request (see [commits and CI](https://lessloc.org/guide/ci.md)). Looser matches are notes too: `near-copy` (75% or more the same code), `similar-name`, and `repeated-literal` for a format, regex or query copied out of the function that owns it. So are `long-params` (8 or more parameters), `single-impl`, `wrapper` (a function that only forwards) and `vague-name`. Why, of these, only `copy` holds a turn: - A hold costs a whole extra turn, and a wrong one buys nothing. See [why your agent bill grows](https://lessloc.org/guides/costs.md). - Replaying commits that humans already merged shows how often a kind fires on finished work: `copy` fired mostly on code repeated verbatim ([Benchmarks](https://lessloc.org/benchmarks.md#precision-on-code-humans-merged-without-a-model-bench-replay-mjs)). - Whether a deep `if` or a long function should be split depends on the code around it, so these stay notes. The notes also guard against going too far the other way. The `long-function` note asks to split code "where it does several things, not into one-line helpers". The `single-use` note asks to inline a one-line helper "unless its name says something the line cannot". And `clever` is the counterweight to `simpler`, lessloc's check for brute-force idioms, so shorter never means golfed. What you can do: - Ask for the shape in the prompt: "reshape first, then change", "put the rates in a table", "keep the parsing out of the pricing". - Read the notes before you merge, with `lessloc check` or in the CI annotations. - Make a kind hold the turn by listing it in [`interrupt`](https://lessloc.org/guide/configuration.md#interrupt) in `.lessloc.json`, keeping the defaults in it. The agent can answer any hold in one line. - When a refactor and a feature go in one prompt, ask for the feature's test yourself. A prompt that says refactor (or restructure, simplify, clean up, consolidate, reorganize, dedupe) turns off lessloc's check for new behavior without a test, for that whole turn. The [patterns](https://lessloc.org/patterns/index.md) pages give the standard behind each kind, with examples. --- # Why your agent bill grows A coding agent's bill is not only the code it writes. With every model call, the agent reads its whole session again, and it thinks before it acts. This page shows where the money goes in a session, why every extra turn costs more than it looks, what lessloc itself costs, and which levers work. On the [benchmarks](https://lessloc.org/benchmarks.md#results), lessloc cost 23% less per task, its own hooks included, with the same results on the projects' own tests. ## Where the money goes With prompt caching, as Claude Code uses it, a model call is billed in these parts: | Part of the bill | What it is | |---|---| | Writing to the cache | What is new to the session: the agent tool's own system prompt and tool definitions when a session starts, then each new message and tool output | | Reading from the cache | What is already in the session, sent again with each model call | | Output | The model's reasoning and its replies | | Uncached input | Input that is neither written to nor read from the cache | Reading from the cache costs much less per token than writing to it, so the tokens a session carries cost less than their count suggests. To see how the parts add up, follow one session. Every model call sends the whole session again: the agent tool's system prompt and tool definitions, your instructions, every message and every tool output so far. What was sent before is read from the cache. What is new, such as the output of the last command, is written to the cache. Then the model thinks and answers, and that is output. Three things follow: - **A new session pays an entry fee** before it reads a single file of yours: the agent tool's own system prompt and tool definitions, written to the cache. - **Every tool output is paid for more than once.** It is written to the cache when it arrives, then read again by every later call in the session. A long test log read early in a long session is carried to the end. - **Output, the model's reasoning and replies, is paid for on every call**, and what you tell an agent changes how much it thinks. lessloc's hooks run on your machine and call no model ([how long they take](https://lessloc.org/guide/faq.md#does-lessloc-slow-my-agent-down)). In money, lessloc costs the text it adds to a session, and whatever that text and its holds make the agent do. ## Why each extra turn costs A turn runs from your prompt to the agent's final reply. When something sends the agent back after it said it was done, the session goes on: at least one more model call that reads the whole session from the cache, new reasoning, usually a command or two whose output is written to the cache and read by every call after it, and a new final message. The longer the session, the more each of those calls re-reads. A turn added at the end of a session is the most expensive kind. A turn that changes nothing, such as one spent on work that was already done, costs all of this too. ## What lessloc itself costs [A session at a glance](https://lessloc.org/guide/how-it-works.md#a-session-at-a-glance) lists the tokens lessloc adds at each hook event, and in one scripted session. [Benchmarks](https://lessloc.org/benchmarks.md#results) has what each setup cost per task. The tokens of a rule are cheap: a short standard is small next to the agent tool's own system prompt and tool definitions. What a rule makes agents do is not cheap: extra test runs, longer thinking, turns sent back. That is [why the standard is short](https://lessloc.org/guide/how-it-works.md#why-the-standard-is-short). ## The levers that work ### Say less Every sentence an agent reads at the start of a session is a behavior it may act on, and it may act on it more widely than you meant: a rule written for bug fixes can be applied to new features too. Keep the sentences that change outcomes and cut the rest. The same goes for your own instruction files and for what tools print. lessloc notes always-loaded instructions that grow past `instructionBudget` or gain a directory tour ([long instructions](https://lessloc.org/patterns/long-instructions.md)). And since every tool output is carried to the end of the session, a quiet test run, such as `pytest -q` or a `tail` that keeps the summary line, costs less to carry. lessloc still reads its result. ### Never force a turn without near-certainty A hold is the most expensive thing a tool can do to a session, and a wrong hold buys nothing: send an agent back only when you are nearly sure, once, with exactly what to fix ([Hold only on near-certain evidence](https://lessloc.org/guides/verification.md#hold-only-on-near-certain-evidence)). ### Give facts that save exploration An agent that has to find something pays for every search, every file it opens and every output it reads, until the session ends. A fact handed over at the right moment is cheaper. - **Existing code.** When a specific word of the prompt names existing code, lessloc points the agent to it ([hints](https://lessloc.org/guide/how-it-works.md#each-prompt-what-your-code-already-has)). - **The test command.** An agent that does not know how to run the project's tests spends calls finding out. Put the test command in one line of the instructions your agent tool already loads, such as AGENTS.md or CLAUDE.md, so the agent knows it up front ([your part](https://lessloc.org/guides/verification.md#your-part)). - **Unfinished work.** When the last session left uncommitted changes, the next one starts with [a record of them](https://lessloc.org/guide/how-it-works.md#the-handoff) instead of rediscovering them. ### Leave less code for every later session Every line an agent leaves is read again by later sessions that touch it. How much code agents with and without lessloc added on real changes to real projects is on the [Benchmarks](https://lessloc.org/benchmarks.md#results) page. [Why less code](https://lessloc.org/why-less-code.md) collects the outside evidence on what extra code costs later. --- # Making agents verify their work When an agent ends with "done, all tests pass", that is a claim. The run behind it may have tested an earlier version of the code, or one test file, or sat behind a pipe that hid the failure. There may have been no run at all. This page explains what counts as evidence that work is done, how agents actually run tests and how lessloc reads each way, why lessloc sends an agent back at most once per turn, and why only on near-certain evidence. lessloc never runs your tests during a session. The agent runs them, and lessloc reads the result from the agent tool. A turn runs from your prompt to the agent's final reply. When a run fails on the final code, or the turn changed how the tests run, lessloc holds the turn: before the agent may finish, it gets one message that lists what is unfinished. ## What counts as evidence A test run shows that the work is done only when all of these are true: 1. **It ran on the final state.** lessloc snapshots the working tree, untracked files included, before and after each command that may change files or run tests, and keeps a result only for the exact tree it ran on. An edit after the run means the run no longer speaks for the code. The one exception: a run still counts when only files that are not code, such as a README or a test cache, changed since. 2. **It ran the whole suite.** A run of one file, a name filter (`-k`, `-t`, `--filter`, `go test -run`), the last failures (`--lf`) or one package of a monorepo (`cargo test -p`) is a part. A part that passes says nothing about the rest. A part that fails still counts as a failure. A Python run that names every test file the project has counts as the whole suite. 3. **It ran the project's own tests.** That is the `test` command in `.lessloc.json`, or [the one lessloc finds](https://lessloc.org/guide/configuration.md#test). A run of the configured command is the whole suite, and so is a whole-suite run of the same runner: for `uv run pytest -q`, also `pytest`, `python3 -m pytest -x` or `uv run pytest`; for a unittest command, pytest too; for `npm test`, the runner its test script calls, unless the script calls more than one. Only arguments that select a part narrow a run. A script lessloc cannot read, such as `./scripts/test.sh`, counts only when it is run itself. A command lessloc found is a guess, so a run must test the language the turn changed: a jest run says nothing about a Python change, and a pytest run counts for one even where the command found is `npm test`. 4. **Its result is the runner's own.** lessloc takes the result the agent tool reports: Claude Code's failure event, the exit code OpenCode reports, the exit code in Cursor's documented hook payload, or the test runner's own summary where Codex reports only output. Where the tool reports no exit code, or something hid it, only the runner's own summary line counts, in that runner's own format: `5 passed in 0.12s` for pytest, `Tests: 3 passed, 3 total` for jest, `test result: ok.` for cargo. The agent's own words never count. 5. **The turn did not change what the tests run.** If the agent edited `pytest.ini`, `tox.ini`, the pytest or coverage section of `pyproject.toml`, the test scripts in `package.json`, a Makefile test target, a jest or vitest config, Maven's surefire settings or the like, a pass no longer shows that the code works. This holds even when no code changed, because that is how checks get weakened. It does not apply when the request named the file. Two problems hold the turn, each as one line of its note, from [lib/voice.mjs](https://github.com/rextail-ai/lessloc/blob/main/lib/voice.mjs): a run that failed on the final state, and a change to how the tests run. ```text The tests fail on the final state (`python -m pytest -q`): fix the cause, or say why the failure is expected. You changed how the tests run this turn (`pytest.ini`), so a pass no longer shows the code works: revert that, or say in one line why it is needed. ``` Every other gap, such as no run, a part of the suite, another runner, or a result lessloc cannot read, leaves the final state unverified without holding the turn. The handoff record says the tests did not run at the final state, and CI is where a passing run is required ([test evidence](https://lessloc.org/guide/ci.md#test-evidence)). A subagent is not held for tests either: its work is usually one part of a change, verified when the turn that holds it ends. ## How agents run tests, and how lessloc reads each Agents run tests in many ways besides the bare command. Here are the common ones, and what lessloc makes of each. Where it cannot tell, it errs toward "not shown": a wrong "passed" would record unverified work as verified. ### The runners lessloc recognizes The project's test command, and the common runners: pytest, unittest, tox, nox, `npm test` (and its yarn, pnpm and bun forms), `bun test`, jest, vitest, mocha, `node --test`, `go test`, `cargo test` and nextest, `dotnet test`, rspec, ctest, `deno test`, phpunit, `mix test`, `make test` or `make check`, Gradle's `test` or `check`, and Maven's `test` or `verify`. ### The plain run ```sh python -m pytest -q ``` The exit status is the runner's, so it decides. It outranks any line of output, which a test name or a log message could echo. ### Wrappers and interpreter options ```sh uv run pytest -x env PYTHONPATH=. python3 -m pytest python -I -m pytest -q python3 -X dev -m unittest discover -s tests bash -lc 'python -m pytest -q' ``` lessloc looks through wrappers such as `env`, `time`, `timeout`, `uv run`, `poetry run`, `npx` and `coverage run -m`, through interpreter options before `-m`, and into a command wrapped in `bash -lc '...'`. ### Pipes that trim the output ```sh python -m pytest -q 2>&1 | tail -3 npm test 2>&1 | grep -E "^# (pass|fail)" ``` A pipeline returns the exit status of its last command, so the runner's own status is lost unless `set -o pipefail` came first. When it is lost, only the runner's summary counts. `grep`, `head`, `tail`, `sort` and `uniq` keep or drop whole lines, so they cannot print a summary the runner did not; a summary that survives them counts, pass or fail. If the summary was cut off, as with `| head -1`, the run shows nothing either way. ### Pipes that can rewrite lines ```sh python -m pytest -q 2>&1 | tail -1 | cut -d, -f2 ``` `sed`, `awk`, `cut`, `tr`, `jq`, `xargs`, `grep -o` and `head -c` can change what a line says. `cut -d, -f2` turns `1 failed, 21 passed in 0.1s` into `21 passed in 0.1s`, which reads as a pass. So after a stage like these, lessloc believes a failure it sees, and never a pass. ### Exit status thrown away ```sh go test ./... || true cargo test; exit 0 python -m pytest -q; git diff --stat ``` The command's exit status is no longer the runner's, so again only the runner's own summary counts. ### Chains ```sh cargo fmt && cargo test pip install -e . && python -m pytest -q ``` With `&&`, a failing exit can come from any command in the chain. It counts against the tests only when the runner's summary shows a failure. If `pip install` failed, pytest never ran, and the run shows nothing. A passing exit means every command passed. lessloc also has to know which state a run tested. It takes the state before the command when nothing ahead of the runner can change files (only reading commands, such as `cd`, `ls`, `cat`, `grep` or `git status`, come before it), and the state after it when nothing behind the runner can. After `cargo fmt &&`, the run tested the formatted code, and that is the state it counts for. ### Checkers after the tests ```sh black . && python -m pytest -q && ruff check . && mypy src ``` Linters and type checkers that only report, such as `ruff check`, `mypy`, `pyright`, `flake8`, `pylint`, `eslint`, `prettier --check`, `tsc --noEmit`, `go vet`, `cargo check` and `cargo clippy`, change no files, so the run still speaks for the final state. If one of them fails the chain, the runner's summary decides whether the tests passed. Their fix modes (`ruff check --fix`, `eslint --fix`, `prettier --write`, or `black` without `--check`) change files and count as changes. ### Stash and retest ```sh git stash && python -m pytest -q 2>&1 | tail -3; git stash pop && python -m pytest -q ``` Agents do this to show that a test fails without the fix and passes with it. Both runs are in one command, so its output holds two summaries. For pytest, unittest and node's test runner, lessloc gives each run its own closing line, in order, when every run printed exactly one. The latest whole-suite run it can tie to a state speaks for the command: here, the last one, on the restored code. The stashed run's failure is never the final state's. If the summaries cannot be told apart, for example because one was cut off, the command counts as neither a pass nor a failure. ### Background runs ```sh python -m pytest -q & ``` A run sent to the background, with `&` or the agent tool's own background option, has no result when the command returns. Neither has a run that was interrupted, timed out or denied. lessloc counts none of them. ### Somewhere else ```sh cd ../other-checkout && python -m pytest -q cd packages/api && npm test ``` lessloc follows `cd` and a runner's folder options (`make -C`, `npm --prefix`, `cargo --manifest-path`). A run outside this checkout proves nothing here. A run in a subfolder, such as one package of a monorepo, is a part, unless that folder is the test folder itself. ### Commands that run no tests ```sh python -m pytest --collect-only cargo test --no-run npx jest --listTests tox -e lint ``` Listing tests, compiling them, skipping them, rewriting snapshots, asking for help, or running an environment named for something other than tests runs no tests. None of these is a test run. ## Why one hold per turn lessloc sends an agent back at most once per turn, with one message that lists what is unfinished ([how the hold works](https://lessloc.org/guide/how-it-works.md#end-of-turn-unfinished-work-is-held-once)). There are three reasons: - **Each hold is a whole extra turn.** A turn at the end of a session is the most expensive kind: the agent re-reads the whole session, thinks again and usually runs more commands. See [why your agent bill grows](https://lessloc.org/guides/costs.md). - **One hold keeps a misread cheap.** It costs at most one turn, and the agent can answer it in a line. Without a limit, one misreading could send an agent back again and again. - **Repeated review grows code.** An agent sent back over and over can start adding layers, checks or tests just to satisfy the reviewer ([review bloat](https://lessloc.org/patterns/index.md#review-bloat)). One hold that lists everything, and asks for nothing more, avoids that loop. The binding check is CI ([what each layer stops](https://lessloc.org/guide/ci.md#what-each-layer-stops)); the agent hooks fail open by design. ## Cases that are easy to misread Some of what agents do looks like a failure, a hidden result or a trick when it is not. This is how lessloc reads each: | What the agent did | How lessloc reads it | |---|---| | Stashed its fix, ran the tests to watch them fail, restored the fix and ran them again, all in one command | Each run owns its own closing summary, and the last run tied to a state speaks for the command | | Piped the run through `grep` or `head` to shorten the output | A summary that survives counts; only stages that can rewrite lines, such as `sed`, `awk`, `cut`, `grep -o` or `head -c`, hide a pass | | Ran `python -I -m pytest` | Interpreter options before `-m` are understood | | Ran `ruff check`, `mypy`, `eslint` or `tsc --noEmit` after the tests | Checkers that only report count as changing nothing | | Ran pytest 9, which prints subtest counts (`29 passed, 5 subtests passed in 0.16s`) | Subtest counts are read, failures included | | Wrote a value the request itself named, such as an email domain the user asked to treat specially | A value the request names is what was asked for, not code shaped to pass a test | ## Hold only on near-certain evidence A wrong "failed" costs a whole turn and changes nothing. A wrong "passed" records unverified work as verified. So lessloc holds only on a failure it can tie to the final state, or on a change to how the tests run, and its reading is checked: - Each of the cases that are easy to misread has a test, in [test/evidence.test.mjs](https://github.com/rextail-ai/lessloc/blob/main/test/evidence.test.mjs) and [test/hooks.test.mjs](https://github.com/rextail-ai/lessloc/blob/main/test/hooks.test.mjs). - `node bench/replay.mjs` replays commits that humans already merged, without a model, and counts how often each kind fires on that finished work ([results](https://lessloc.org/benchmarks.md#precision-on-code-humans-merged-without-a-model-bench-replay-mjs)). - `bench/candidates.mjs` replays, through the hooks, the human change for each judged task of a real project, and variants of it that leave work out, to count false rejects and false accepts ([method](https://lessloc.org/benchmarks.md#refusing-unfinished-work-without-a-model-bench-candidates-mjs)). When lessloc truly cannot tell, because a summary was cut off, a run is still in the background, or two runs cannot be told apart, it counts the run as neither a pass nor a failure, and the turn is not held. The same rule applies to anything you build around an agent, such as a hook, a review bot or a CI loop that makes it retry. Send it back only when you are nearly sure, once, with exactly what to fix. ## Your part - Tell the agent the test command, in one line of the instructions your agent tool already loads, so it does not spend calls guessing. Set `test` in `.lessloc.json` too, so a run of it counts as the whole suite (see [configuration](https://lessloc.org/guide/configuration.md#test)). - Let the summary through. `-q`, `| tail -n 20` and `| grep` are fine as long as the runner's summary line survives. Avoid `|| true` and `; exit 0`. - Make `lessloc ci` a required check, so evidence is checked where an agent cannot skip it. ## What a passing run cannot tell you A passing suite shows only what the tests check. lessloc also holds a turn for new behavior with no test changed (`untested`, [with some exceptions](https://lessloc.org/guide/how-it-works.md#end-of-turn-unfinished-work-is-held-once)), for tests that assert nothing or mock the code they test (`empty-test`), and for tests deleted, skipped, focused or forced to pass (`weakened-tests`). See the [tests and verification patterns](https://lessloc.org/patterns/index.md), and [unverified](https://lessloc.org/patterns/index.md#unverified) for the hold described on this page. --- # Why less code AI coding agents bloat software in recurring, documented ways. [The pattern catalog](https://lessloc.org/patterns/index.md) lists each one, the software-engineering standard that already calls it a bad practice, and what lessloc does about it. This page explains why unneeded size does more harm than good, and why agents need this more than people do. ## Why unneeded size does more harm than good Code, text, files and folders that a requirement does not need cost something every time anyone works on the software: - **Every line is spent, not produced.** Dijkstra (EWD1036): "if we wish to count lines of code, we should not regard them as 'lines produced' but as 'lines spent'." - **Complexity grows unless someone reduces it.** Lehman's second law: "As an evolving program is continually changed, its complexity, reflecting deteriorating structure, increases unless work is done to maintain or reduce it." - **Errors grow with size.** McConnell, *Code Complete*, ch. 27: error density rises with program size. - **Duplicated knowledge breaks.** A rule written in two places gets fixed in one. DRY says "every piece of knowledge must have a single, unambiguous, authoritative representation within a system." - **Big changes get worse reviews.** Google's engineering practices: "100 lines is usually a reasonable size for a CL, and 1000 lines is usually too large." - **For AI agents, size is attention.** - Models do worse as their input grows: "performance grows increasingly unreliable as input length grows" ([Chroma, Context Rot](https://research.trychroma.com/context-rot)). - Models miss what sits in the middle of long contexts ([Liu et al., TACL 2024](https://aclanthology.org/2024.tacl-1.9/)). - Anthropic calls context "a finite resource with diminishing marginal returns." Every extra line, comment, file or doc is read by every later agent session, and it competes with what matters. - **Comments and docs that restate code drift into lies.** [CWE-1116](https://cwe.mitre.org/data/definitions/1116.html), Inaccurate Source Code Comments. *Code Complete* counts a comment that repeats the code as unacceptable in finished code. - **Fragmentation costs navigation.** Shallow files and folders hold little behind each name (Ousterhout). Always-loaded overviews raised agents' cost by more than 20% without helping ([arXiv:2602.11988](https://arxiv.org/abs/2602.11988)). None of this applies to code a requirement needs. Tests, validation, error handling, security and the docs a reader needs are never "bloat", and duplication is still cheaper than the wrong abstraction (Metz). lessloc targets growth nobody needs, and [the counterweights](https://lessloc.org/patterns/index.md#what-lessloc-never-pushes-an-agent-toward) keep it from going further. ## Why agents need this more than people do - **Duplication up, reuse down.** From 2021 to 2024, copy-pasted lines rose from 8.3% to 12.3% of changed lines, and moved or refactored lines fell from 25% to under 10% (211M lines; [GitClear 2025](https://www.gitclear.com/ai_assistant_code_quality_2025_research)). - **Lasting complexity.** Adopting Cursor brought +30% static-analysis warnings and +42% cognitive complexity in about 800 repositories. Both persisted while the speed gain faded ([arXiv:2511.04427](https://arxiv.org/abs/2511.04427)). - **Short-lived code.** Functions and classes that agents add are deleted after a median of 3 days, against 34 for human code ([arXiv:2601.17627](https://arxiv.org/abs/2601.17627)). - **Debris in passing patches.** - In passing patches, 18–33% of the patch could be removed with the tests still passing ([arXiv:2607.18161](https://arxiv.org/abs/2607.18161)). - 29% of passing SWE-bench patches wrapped obsolete code in a guard instead of deleting it ([arXiv:2607.28887](https://arxiv.org/abs/2607.28887)). - **Weak tests.** - 80% of agent test patches have a weak oracle or none ([arXiv:2606.18168](https://arxiv.org/abs/2606.18168)). - Agent commits add mocks more often than human commits: 36% against 26% ([arXiv:2602.00409](https://arxiv.org/abs/2602.00409)). - **Type escapes.** In TypeScript agent pull requests, `any` appears at 9 times the human rate ([arXiv:2602.17955](https://arxiv.org/abs/2602.17955)). - **Made-up packages.** Between 5.2% and 21.7% of the packages that models recommend do not exist ([Spracklen et al., USENIX Security 2025](https://www.usenix.org/conference/usenixsecurity25/presentation/spracklen)). - **The cost lands on the next agent.** Cleaner code cut Claude Code's tokens by 7–8% and its file revisits by 34%, at an equal pass rate ([arXiv:2605.20049](https://arxiv.org/abs/2605.20049)). - **Less stable delivery.** A 25% rise in AI adoption went with 7.2% lower delivery stability ([DORA 2024](https://dora.dev/ai/gen-ai-report/)). The figures come from the cited studies, as each reports them, not from lessloc. lessloc's own measurements are in [Benchmarks](https://lessloc.org/benchmarks.md). --- # Patterns Every way lessloc knows that coding agents bloat code, comments, files, folders, dependencies and tests, with the standard that calls each one a bad practice and what lessloc does about it. Each pattern's page, or its section on this page, says what lessloc does about it: - **Rule**: the line in [RULES.md](https://github.com/rextail-ai/lessloc/blob/main/RULES.md) that prevents it. Every session gets the rules; in Claude Code, each subagent gets a one-line working standard. - **Kind**: the finding lessloc reports when it happens anyway. **Stops** marks a kind that holds an agent's turn once, sending it back with what is unfinished. Tier-0 kinds also fail `lessloc check`. All other kinds are notes to the user. - **When**: *write* means right after each edit, while the code is still open; *turn* means when the agent says it is done; *check* means commit and CI; *audit* means the whole codebase. Every detected pattern is proven by a test: bad and good forms in [test/cases.mjs](https://github.com/rextail-ai/lessloc/blob/main/test/cases.mjs), where the bad form must be caught and the good form must not be, so the push for less code never flags what the code needs; or a named test. [test/catalog.test.mjs](https://github.com/rextail-ai/lessloc/blob/main/test/catalog.test.mjs) fails when a pattern has neither. The standards cited are the ones engineers already use: - **CWE weaknesses.** Those in the maintainability measure of ISO/IEC 5055:2021, the international standard for automated source-code quality, are [561](https://cwe.mitre.org/data/definitions/561.html), [1041](https://cwe.mitre.org/data/definitions/1041.html), [1047](https://cwe.mitre.org/data/definitions/1047.html), [1064](https://cwe.mitre.org/data/definitions/1064.html), [1074](https://cwe.mitre.org/data/definitions/1074.html), [1080](https://cwe.mitre.org/data/definitions/1080.html), [1085](https://cwe.mitre.org/data/definitions/1085.html) and [1121](https://cwe.mitre.org/data/definitions/1121.html). - **Linter rules** that ship enabled in common configurations. - **Refactoring, clean-code and testing literature.** - **The model vendor's own guidance.** Anthropic's prompting guide says recent Claude models "have a tendency to overengineer by creating extra files, adding unnecessary abstractions, or building in flexibility that wasn't requested." Sonar now ships a quality profile for agent-written code that targets "code duplication and structural drift" and "hallucinated APIs and supply-chain risks". ## Code that already exists - [Reimplements a helper the project already has, under the same or another name](https://lessloc.org/patterns/reuse-helper.md) - [Copies a rule another function owns: a format, regex, SQL or date pattern](https://lessloc.org/patterns/copied-rule.md) - [Keeps old and new versions side by side (`parse_date_v2`, `fooFixed`, `legacyFoo`, `app_new.py`)](https://lessloc.org/patterns/side-by-side.md) - [Rebuilds what the standard library or framework has: counters, flag loops, CSV, date math, argument parsing](https://lessloc.org/patterns/rebuilt-library.md) - [Copy-pasted test cases instead of one parametrized test](https://lessloc.org/patterns/copied-tests.md) - [Adds a second library for a job an installed one already does (dayjs beside moment)](https://lessloc.org/patterns/second-library.md) - [The same data shape in several layers (DTO, model, entity)](https://lessloc.org/patterns/duplicate-shapes.md) - [The same CSS declarations under another selector; the same long utility-class string again and again](https://lessloc.org/patterns/css-copy.md) ## Code nobody needs - [Options, flags, parameters or config for needs nobody named](https://lessloc.org/patterns/speculative-options.md) - [An interface, trait, protocol or abstract class with one implementation](https://lessloc.org/patterns/single-impl.md) - [Wrappers that only forward; barrel files that only re-export](https://lessloc.org/patterns/wrappers.md) - [A new function or import that nothing calls or tests](https://lessloc.org/patterns/unused.md) - [Obsolete code kept behind a guard or flag instead of deleted](https://lessloc.org/patterns/guarded-dead-code.md) - [Tombstones: `// removed X`, "kept for backward compatibility", comments citing the task](https://lessloc.org/patterns/tombstones.md) - [Stubs and fake success: "in a real app you would...", `raise NotImplementedError`, `todo!()`, `throw new Error("Not implemented")`, `mockUsers` in production code](https://lessloc.org/patterns/stubs.md) - [Placeholders: `# ... existing code ...`, "your code here"](https://lessloc.org/patterns/placeholders.md) - [Commented-out code](https://lessloc.org/patterns/commented-out.md) - [Debug leftovers: `debugger`, `breakpoint()`, `print("here")`, `print(f"{x=}")`, `dbg!`](https://lessloc.org/patterns/debug-leftovers.md) - [A demo `if __name__ == "__main__":` block in a module other code imports; an examples or demo folder nobody asked for](https://lessloc.org/patterns/demo-code.md) - [Hand-written boilerplate a language feature writes (an `__init__` that only stores its parameters)](https://lessloc.org/patterns/boilerplate.md) - [A one-line helper used once](https://lessloc.org/patterns/single-use.md) - [TODO and FIXME left behind: work the agent did not finish and did not mention](https://lessloc.org/patterns/todo.md) ## Code longer than it needs to be - [Brute-force idioms: `== True`, an if/else that returns a boolean, index loops that only read `xs[i]`, `.filter(...).length > 0`, `indexOf(x) !== -1`, `x ? true : false`, `x ? x : y`, `.map().flat()`, `strings.Index(...) != -1`, `.len() == 0`](https://lessloc.org/patterns/brute-force.md) - [Hand-built counters and flag loops](https://lessloc.org/patterns/counters-and-flags.md) - [A catch that only rethrows; logging an error and rethrowing it](https://lessloc.org/patterns/catch-rethrow.md) - [A promise wrapped in `new Promise`](https://lessloc.org/patterns/promise-wrap.md) - [Annotating types the compiler infers](https://lessloc.org/patterns/inferred-types.md) - [A React Effect that only sets state from other values](https://lessloc.org/patterns/derived-effect.md) - [Functions with many parameters (8 or more)](https://lessloc.org/patterns/long-params.md) - [Deep nesting](https://lessloc.org/patterns/deep-nesting.md) - [Long functions](https://lessloc.org/patterns/long-function.md) - [If-chains on literal values](https://lessloc.org/patterns/if-chain.md) - [A file that keeps growing](https://lessloc.org/patterns/swollen-file.md) - [Reformatting lines the task did not change](https://lessloc.org/patterns/churn.md) - [Net growth past a budget](https://lessloc.org/patterns/net-growth.md) - [Golfed code: nested ternaries, comprehensions with several `for` clauses, lines over 160 characters](https://lessloc.org/patterns/golfed-code.md) ## Errors and checks hidden - [Swallowed errors: `except: pass`, an empty catch, `catch (Exception e) { return null; }`, Go's `if err != nil { return nil }`](https://lessloc.org/patterns/swallowed-error.md) - [Silenced checks: `@ts-ignore`, `any`, blanket `# type: ignore` or `# noqa`, `eslint-disable` without a rule, `#[allow(dead_code)]`, `//nolint` without a reason](https://lessloc.org/patterns/silenced-checks.md) - [Checks, fallbacks and catches for cases that cannot happen](https://lessloc.org/patterns/defensive.md) - [Removing validation, error handling, logging or stated rules to shrink code](https://lessloc.org/patterns/removed-safeguard.md) ## Shortcuts that trade away safety Hand-written versions of what the platform does safely. They are short, which is why agents reach for them. - [SQL built from values with f-strings, concatenation or template strings](https://lessloc.org/patterns/sql-from-values.md) - [HTML built or escaped by hand: `innerHTML` from values, `.replace('<', '<')`](https://lessloc.org/patterns/html-by-hand.md) - [`Math.random()` or `random` for tokens, passwords and session ids](https://lessloc.org/patterns/weak-randomness.md) - [Fast hashes (MD5, SHA-1, SHA-256) for passwords](https://lessloc.org/patterns/password-hash.md) - [Certificate checks turned off: `verify=False`, `rejectUnauthorized: false`, `InsecureSkipVerify: true`](https://lessloc.org/patterns/tls-off.md) - [Running text as code: `eval`, `exec`, `new Function`](https://lessloc.org/patterns/eval.md) ## Calls and packages that do not exist - [Another language's API: `null` or `===` in Python, `.strip()`, `None` or `len()` in JavaScript, `null` in Go](https://lessloc.org/patterns/wrong-language-api.md) - [Imports of packages no manifest declares: a made-up name, or one never installed](https://lessloc.org/patterns/undeclared-import.md) ## Comments and text - [Comments that narrate what the code does ("Loop through the users")](https://lessloc.org/patterns/narration.md) - [Banners, dividers, closing-brace comments, emoji decoration](https://lessloc.org/patterns/decoration.md) - [Docs that restate the name (`"""Get the user."""`, `name: The name.`, `@returns The result.`)](https://lessloc.org/patterns/restating-docs.md) - [History and messages to the requester ("Updated the parser to...", "As requested", "Generated by AI", dated change notes)](https://lessloc.org/patterns/history.md) - [Comment blocks and comment-heavy changes](https://lessloc.org/patterns/comment-weight.md) - [Log lines that narrate each step ("Starting...", "Successfully saved")](https://lessloc.org/patterns/log-chatter.md) - [Docs that still name what a change removed](https://lessloc.org/patterns/stale-doc.md) - [Summary, report and notes files (`IMPLEMENTATION_SUMMARY.md`, `FIXES.md`)](https://lessloc.org/patterns/index.md#report-files) - [Doc files nobody asked for, and READMEs that grow by 40 or more lines nobody asked for](https://lessloc.org/patterns/unrequested-docs.md) - [Docs deleted wholesale to shrink a change](https://lessloc.org/patterns/doc-removed.md) - [Always-loaded instructions that grow, or that include a directory tour](https://lessloc.org/patterns/long-instructions.md) - [Commit messages that recap the diff file by file](https://lessloc.org/patterns/commit-recaps.md) ### Summary, report and notes files {#report-files} Summary, report and notes files (`IMPLEMENTATION_SUMMARY.md`, `FIXES.md`). lessloc catches it as [`junk-file`](https://lessloc.org/guide/reference.md#tier-0) and refuses it before it lands. - `junk-file` (tier 0): A new backup, copy, scratch or report file (refused before it lands, unless the request names it), or cache files, build output or logs that would be committed. ## Files and folders - [Scratch, backup, copy and harness files (`debug_x.py`, `.bak`, `utils_old.py`, `tmpclaude-*`)](https://lessloc.org/patterns/index.md#scratch-files) - [Caches, build output and logs about to be committed](https://lessloc.org/patterns/index.md#caches) - [Dockerfiles, compose files, CI workflows and deploy configs nobody asked for](https://lessloc.org/patterns/scaffolding.md) - [Tiny one-function files; file-per-concern splits](https://lessloc.org/patterns/tiny-files.md) - [New folders for one or two files; layer trees for small apps](https://lessloc.org/patterns/one-file-folders.md) - [Junk-drawer `utils` and `helpers` modules and folders](https://lessloc.org/patterns/junk-drawer.md) - [Vague names (`process_data`, `handle`)](https://lessloc.org/patterns/vague-name.md) - [Moving or renaming files without a reason](https://lessloc.org/patterns/moved-files.md) - [Secrets: a committed `.env`, API keys, private keys](https://lessloc.org/patterns/secrets.md) ### Scratch, backup, copy and harness files {#scratch-files} Scratch, backup, copy and harness files (`debug_x.py`, `.bak`, `utils_old.py`, `tmpclaude-*`). lessloc catches it as [`junk-file`](https://lessloc.org/guide/reference.md#tier-0) and refuses it before it lands. A root `test_*.py` is allowed when the project has no tests folder. - `junk-file` (tier 0): A new backup, copy, scratch or report file (refused before it lands, unless the request names it), or cache files, build output or logs that would be committed. ### Caches, build output and logs about to be committed {#caches} lessloc catches it as [`junk-file`](https://lessloc.org/guide/reference.md#tier-0). - `junk-file` (tier 0): A new backup, copy, scratch or report file (refused before it lands, unless the request names it), or cache files, build output or logs that would be committed. ## Tests and verification - [Behavior added with no test changed in the same change](https://lessloc.org/patterns/untested.md) - [Tests that assert nothing, or assert a constant](https://lessloc.org/patterns/empty-test.md) - [Deleted, skipped, focused (`.only`), exited (`sys.exit(0)`) or force-passed tests](https://lessloc.org/patterns/weakened-tests.md) - [Test-only branches in production code; code shaped to pass a test](https://lessloc.org/patterns/test-logic.md) - [Tests that mock the code they test](https://lessloc.org/patterns/mocked-subject.md) - [Production code that branches on a value copied from the tests](https://lessloc.org/patterns/test-shaped.md) - [Stopping short: a function, option or flag the request asked for is never written](https://lessloc.org/patterns/unaddressed.md) - [Stopping short: leaving part of the reported problem failing, and saying so](https://lessloc.org/patterns/unfinished.md) - [Saying "done" without a passing run on the final state](https://lessloc.org/patterns/index.md#unverified) - [A refactor that changes existing tests](https://lessloc.org/patterns/refactor-tests.md) ### Saying "done" without a passing run on the final state {#unverified} lessloc catches it as [`tests-failed`](https://lessloc.org/guide/reference.md#tier-0) and [`unverified`](https://lessloc.org/guide/reference.md#tier-1): the agent's own test runs, as its host reported them, are the evidence for the exact tree; a run that fails on the final state, or a change to how the tests run, holds the turn once, and a final state no run verified is written to the record; `lessloc test`, `verify` and `ci` record evidence too. - `tests-failed` (tier 0): A test run on the final state failed. - `unverified` (tier 1): In a session, the turn changed what the tests run, so a pass no longer shows the code works. With `check --require-evidence`, no passing test run is recorded for exactly the state checked, at tier 0. ## Getting around the checks - [`git commit --no-verify`, `-n`, `HUSKY=0`, `SKIP=`, `LESSLOC=off`](https://lessloc.org/patterns/index.md#bypass) - [Editing lessloc's policy or a protected path](https://lessloc.org/patterns/index.md#policy-edits) - [Suppressing a finding without a reason](https://lessloc.org/patterns/index.md#reasonless-suppression) - [Fixing a review by adding layers, checks or tests](https://lessloc.org/patterns/index.md#review-bloat) ### Skipping commit hooks with --no-verify or LESSLOC=off {#bypass} `git commit --no-verify`, `-n`, `HUSKY=0`, `SKIP=`, `LESSLOC=off`. What lessloc does: The gate asks the user before the command runs. ### Editing lessloc's policy or a protected path {#policy-edits} lessloc catches it as [`policy`](https://lessloc.org/guide/reference.md#tier-0) and [`protected`](https://lessloc.org/guide/reference.md#tier-0); the gate asks the user. - `policy` (tier 0): `.lessloc.json` or `.lessloc-baseline.json` changed in an agent session without your approval. Only in a session: `check`, the commit gate and CI do not report it. - `protected` (tier 0): A path in `protect` changed in an agent session without your approval. Only in a session: `check`, the commit gate and CI do not report it. ### Suppressing a finding without a reason {#reasonless-suppression} What lessloc does: `lessloc-ignore` works only with a reason. `check` and CI list every suppression. ### Fixing a review by adding layers, checks or tests {#review-bloat} What lessloc does: The turn's hold ends with "Fix only these and add nothing else for this note". ## Project policy These kinds hold what the project's owner decided, so an agent cannot change them on its own. - [A broken or misspelled `.lessloc.json` that would silently fall back to defaults](https://lessloc.org/patterns/index.md#config) - [Imports across a boundary the project forbids (for example, UI importing the database)](https://lessloc.org/patterns/index.md#boundary) - [A dependency outside the approved list](https://lessloc.org/patterns/index.md#approved-dependencies) - [Duplication, dead code, comment weight, swallowed errors or test assertions getting worse](https://lessloc.org/patterns/index.md#ratchet) ### A broken or misspelled .lessloc.json {#config} A broken or misspelled `.lessloc.json` that would silently fall back to defaults. lessloc catches it as [`config`](https://lessloc.org/guide/reference.md#tier-0). - `config` (tier 0): `.lessloc.json` is not valid JSON, or has an unknown key, a wrong type or an exception without a reason. It never refuses a commit. ### Imports across a boundary the project forbids {#boundary} Imports across a boundary the project forbids (for example, UI importing the database). lessloc catches it as [`boundary`](https://lessloc.org/guide/reference.md#tier-0). - `boundary` (tier 0): An added import breaks a rule in `boundaries`. ### A dependency outside the approved list {#approved-dependencies} lessloc catches it as [`dependency`](https://lessloc.org/guide/reference.md#tier-0). - `dependency` (tier 0): An added dependency is not in `approvedDependencies`. ### Duplication, dead code or swallowed errors getting worse {#ratchet} Duplication, dead code, comment weight, swallowed errors or test assertions getting worse. lessloc catches it as [`ratchet`](https://lessloc.org/guide/reference.md#tier-0). It warns, comparing with the base commit; with a committed baseline, it fails. - `ratchet` (tier 0): A codebase measure got worse than its line: more duplication, copies, similar names, unreferenced definitions, comments, commented-out code, swallowed errors, pass-through wrappers, junk files or instruction tokens, or fewer test assertions. Tier 0 only against a baseline this version recorded. ## Context and handoff - [Test output re-read again and again](https://lessloc.org/patterns/index.md#test-output) - [The next agent cannot tell what was verified or left open](https://lessloc.org/patterns/index.md#handoff) - [Hunting for where things live](https://lessloc.org/patterns/index.md#findability) - [Several agents in one checkout, editing the same files](https://lessloc.org/patterns/index.md#busy-sessions) - [Tool state cluttering the repository, its history or teammates' setups](https://lessloc.org/patterns/index.md#footprint) ### Test output re-read again and again {#test-output} What lessloc does: `lessloc test`, for people and CI (agents run the project's own command), prints one line on success and only the failing parts on failure. ### The next agent cannot tell what was verified or left open {#handoff} What lessloc does: The next session in the checkout gets the last one's uncommitted work, verification and open problems in its own words; evidence tied to the exact tree; commit trailers when the team turns them on. ### Hunting for where things live {#findability} What lessloc does: Existing code named on a prompt when a specific word of its name matches; the last session's uncommitted work handed over. ### Several agents in one checkout, editing the same files {#busy-sessions} What lessloc does: Each session reviews only what its own tools wrote, so two agents in one checkout never review each other's work. ### Tool state cluttering the repository {#footprint} Tool state cluttering the repository, its history or teammates' setups. What lessloc does: Nothing in the working tree unless asked; state in the user's cache; one trailer line; shared hook folders left alone ([what lessloc writes](https://lessloc.org/guide/reference.md#files-lessloc-writes)). ## What lessloc never pushes an agent toward "Less code" taken too far is its own failure. Each of these has a counterweight: | Over-correction | Counterweight | |---|---| | Cutting tests, validation, error handling or logging | "Never cut correctness, security, tests" in the rules; `removed-safeguard`, `weakened-tests` | | Golfing code into one-liners | `clever` (nested ternaries, comprehensions with several `for` clauses; Google Python style: optimize for readability, not conciseness), `dense-lines` | | Inlining everything to avoid files and helpers | `long-function`, `deep-nesting`, `swollen-file` | | Merging code that only looks alike | The rule keeps code that changes for different reasons separate (Metz, "The Wrong Abstraction": duplication is far cheaper than the wrong abstraction). Copy findings show how widely each copy is used | | Deleting comments that carry a reason | `narration` spares comments with a why (because, so that, must, never, a colon) | | Hiding errors in fewer lines | `contextlib.suppress` is excluded from `simpler` and still counts as a swallowed error | | Syntax the project's runtime does not support | ruff follows the project's target version; truthiness rules are skipped in NumPy and pandas code | | Breaking framework conventions | Route folders and convention files (`page.tsx`, `+page.svelte`, `__init__.py`, `mod.rs`) never count as needless | | Blocking the tests the rules ask for | A root test file counts as scratch only when the project keeps tests in a folder | | Review loops that add code | At most one hold per turn, and it says to fix only what it lists and add nothing else | --- # Benchmarks lessloc is measured on real changes to real open-source projects. Each task is a change a human merged. A fresh agent session gets the project's code from just before that change and the text of its issue or pull request. It is judged by the tests the human's change added, which the agent never sees. The [results](#results) come from this benchmark. The other tools on this page replay finished code without a model. ## Results lessloc 0.10.0 against no tool, with Haiku (claude-haiku-5-5) in Claude Code 2.1.303. There are 117 tasks from three Python projects, two sessions per task for each setup, 468 sessions in all. lessloc wrote less code on every project and cost 23% less overall, with the same results: tasks passed show no measurable difference. - **Same results.** lessloc's sessions passed 157 times out of 234, and no tool's 161: a difference of 2 tasks, with a 95% confidence interval from 5 fewer to 0.5 more, which includes zero. - **Less code.** It wrote 27% fewer production lines per task, with an interval of 23% to 32% fewer. - **Lower cost.** It cost 23% less per task, with an interval of 10% to 31% less, its own hooks included. | | Pass rate: no tool | Pass rate: lessloc | Production code | Cost per task | |---|--:|--:|--:|--:| | starlette, 43 tasks | 76.7% | 75.6% | 26% less | 26% lower | | pydantic-settings, 45 tasks | 60.0% | 57.8% | 23% less | 22% lower | | w3lib, 29 tasks | 70.7% | 69.0% | 36% less | 15% lower | | **All 117 tasks** | **68.8%** | **67.1%** | **27% less** | **23% lower** | Dollar figures are Claude Code's own estimates at API prices. The run measured the lessloc 0.10.0 release candidate; the release adds the precision and command fixes made after the run. In full, as `bench/real/stats.py` prints it from the run's results. Each cell reads no tool → lessloc, with the 95% interval of the difference in parentheses: | | Tasks | Passed: no tool → lessloc, difference in tasks | Production lines per task | Cost per task | Earlier changes broken per task | Test lines per task | |---|--:|--:|--:|--:|--:|--:| | starlette | 43 | 76.7% → 75.6%, −0.5 (−1.5 to 0) | 14.0 → 10.4, −26% (−30% to −19%) | $0.0201 → $0.0148, −26% (−39% to +2%) | 0.30 → 0.16 | 30.5 → 16.8 | | pydantic-settings | 45 | 60.0% → 57.8%, −1.0 (−3.0 to 0) | 17.1 → 13.1, −23% (−33% to −16%) | $0.0250 → $0.0195, −22% (−33% to −9%) | 0.67 → 0.67 | 29.6 → 19.1 | | w3lib | 29 | 70.7% → 69.0%, −0.5 (−2.0 to +1.0) | 14.0 → 8.9, −36% (−49% to −28%) | $0.0129 → $0.0109, −15% (−22% to −7%) | 0.00 → 0.00 | 17.1 → 8.6 | | **All** | 117 | 68.8% → 67.1%, −2.0 (−5.0 to +0.5) | 15.2 → 11.1, −27% (−32% to −23%) | $0.0202 → $0.0156, −23% (−31% to −10%) | 0.37 → 0.32 | 26.8 → 15.6 | ### Every task The tasks of each project, in the order their changes were merged. Each setup ran every task twice: passed is how many of the two sessions passed, and lines and cost are the mean of the two. #### starlette | Task | Merged | Passed: no tool | lessloc | Production lines: no tool | lessloc | Cost: no tool | lessloc | |---|---|--:|--:|--:|--:|--:|--:| | pr3410 | 2026-08-05 | 2/2 | 2/2 | 48 | 40.5 | $0.016 | $0.022 | | pr3418 | 2026-08-06 | 2/2 | 2/2 | 37.5 | 28.5 | $0.011 | $0.010 | | pr3419 | 2026-08-07 | 2/2 | 2/2 | 2 | 1.5 | $0.009 | $0.008 | | pr3420 | 2026-08-07 | 2/2 | 2/2 | 7 | 5 | $0.011 | $0.007 | | pr3421 | 2026-08-07 | 2/2 | 2/2 | 16.5 | 15.5 | $0.008 | $0.007 | | pr3389 | 2026-08-08 | 2/2 | 2/2 | 1 | 1 | $0.005 | $0.005 | | pr3430 | 2026-08-08 | 2/2 | 2/2 | 14 | 4 | $0.012 | $0.011 | | pr3431 | 2026-08-08 | 0/2 | 0/2 | 9 | 7 | $0.011 | $0.011 | | pr3130 | 2026-08-08 | 0/2 | 0/2 | 13 | 7 | $0.013 | $0.008 | | pr3471 | 2026-08-25 | 0/2 | 0/2 | 6.5 | 5 | $0.007 | $0.007 | | pr3472 | 2026-08-26 | 2/2 | 2/2 | 50 | 36 | $0.037 | $0.025 | | pr3436 | 2026-08-26 | 2/2 | 2/2 | 7.5 | 6.5 | $0.017 | $0.013 | | pr2767 | 2026-08-30 | 0/2 | 0/2 | 2 | 6 | $0.009 | $0.010 | | pr3489 | 2026-08-30 | 0/2 | 0/2 | 3 | 3 | $0.016 | $0.014 | | pr3492 | 2026-08-30 | 0/2 | 0/2 | 9.5 | 6.5 | $0.022 | $0.024 | | pr3510 | 2026-09-05 | 2/2 | 2/2 | 3 | 3 | $0.008 | $0.008 | | pr3376 | 2026-09-05 | 2/2 | 2/2 | 2 | 2 | $0.008 | $0.007 | | pr3516 | 2026-09-05 | 2/2 | 1/2 | 16.5 | 11 | $0.014 | $0.011 | | pr3517 | 2026-09-05 | 2/2 | 2/2 | 13 | 6 | $0.014 | $0.013 | | pr3372 | 2026-09-06 | 2/2 | 2/2 | 3 | 3 | $0.006 | $0.008 | | pr3201 | 2026-09-06 | 2/2 | 2/2 | 3 | 2.5 | $0.006 | $0.006 | | pr3476 | 2026-09-06 | 2/2 | 2/2 | 6 | 5.5 | $0.010 | $0.009 | | pr3271 | 2026-09-07 | 2/2 | 2/2 | 6 | 6 | $0.006 | $0.006 | | pr3532 | 2026-09-12 | 2/2 | 2/2 | 6 | 4.5 | $0.008 | $0.008 | | pr3545 | 2026-09-12 | 2/2 | 2/2 | 2 | 2 | $0.006 | $0.007 | | pr3563 | 2026-09-20 | 0/2 | 0/2 | 26 | 20.5 | $0.020 | $0.019 | | pr3568 | 2026-09-23 | 2/2 | 2/2 | 6 | 5 | $0.007 | $0.008 | | pr3583 | 2026-09-26 | 0/2 | 0/2 | 2 | 2 | $0.009 | $0.008 | | pr3567 | 2026-09-27 | 2/2 | 2/2 | 3 | 2.5 | $0.010 | $0.007 | | pr3565 | 2026-10-03 | 2/2 | 2/2 | 20 | 11.5 | $0.021 | $0.015 | | pr3618 | 2026-10-04 | 2/2 | 2/2 | 5.5 | 6 | $0.007 | $0.007 | | pr3617 | 2026-10-04 | 2/2 | 2/2 | 8 | 8 | $0.007 | $0.006 | | pr3186 | 2026-10-04 | 2/2 | 2/2 | 2 | 2 | $0.005 | $0.004 | | pr3640 | 2026-10-06 | 2/2 | 2/2 | 1 | 1 | $0.004 | $0.005 | | pr3651 | 2026-10-09 | 2/2 | 2/2 | 1 | 3 | $0.005 | $0.011 | | pr3631 | 2026-10-13 | 2/2 | 2/2 | 9 | 6.5 | $0.011 | $0.017 | | pr3671 | 2026-10-17 | 0/2 | 0/2 | 4 | 3 | $0.009 | $0.007 | | pr3622 | 2026-10-17 | 2/2 | 2/2 | 12.5 | 6.5 | $0.014 | $0.012 | | pr3491 | 2026-10-18 | 0/2 | 0/2 | 202.5 | 140 | $0.418 | $0.214 | | pr3585 | 2026-10-18 | 2/2 | 2/2 | 2.5 | 2 | $0.008 | $0.009 | | pr3542 | 2026-10-18 | 2/2 | 2/2 | 6 | 4 | $0.008 | $0.010 | | pr3675 | 2026-10-18 | 2/2 | 2/2 | 2 | 1 | $0.005 | $0.006 | | pr3679 | 2026-10-18 | 2/2 | 2/2 | 3 | 3 | $0.006 | $0.008 | #### pydantic-settings | Task | Merged | Passed: no tool | lessloc | Production lines: no tool | lessloc | Cost: no tool | lessloc | |---|---|--:|--:|--:|--:|--:|--:| | pr878 | 2026-06-03 | 0/2 | 0/2 | 50.5 | 20.5 | $0.033 | $0.023 | | pr876 | 2026-06-05 | 2/2 | 2/2 | 24 | 23 | $0.029 | $0.023 | | pr882 | 2026-06-07 | 0/2 | 0/2 | 4 | 8 | $0.011 | $0.008 | | pr889 | 2026-06-19 | 2/2 | 2/2 | 17.5 | 1 | $0.013 | $0.012 | | pr860 | 2026-06-29 | 0/2 | 0/2 | 71 | 65 | $0.218 | $0.105 | | pr898 | 2026-07-06 | 2/2 | 2/2 | 18 | 7.5 | $0.018 | $0.017 | | pr900 | 2026-07-07 | 0/2 | 0/2 | 18 | 10.5 | $0.017 | $0.014 | | pr902 | 2026-07-08 | 2/2 | 2/2 | 39 | 39 | $0.030 | $0.029 | | pr905 | 2026-07-08 | 2/2 | 2/2 | 8.5 | 2 | $0.017 | $0.010 | | pr910 | 2026-07-14 | 2/2 | 0/2 | 13.5 | 6.5 | $0.010 | $0.009 | | pr912 | 2026-07-15 | 2/2 | 2/2 | 2 | 2 | $0.007 | $0.008 | | pr913 | 2026-07-16 | 0/2 | 0/2 | 6 | 5.5 | $0.009 | $0.009 | | pr914 | 2026-07-22 | 0/2 | 0/2 | 3 | 2 | $0.007 | $0.007 | | pr920 | 2026-08-03 | 2/2 | 2/2 | 2 | 1 | $0.009 | $0.010 | | pr926 | 2026-08-06 | 2/2 | 2/2 | 1 | 1 | $0.007 | $0.007 | | pr940 | 2026-08-21 | 0/2 | 0/2 | 38 | 28.5 | $0.026 | $0.021 | | pr945 | 2026-08-23 | 2/2 | 2/2 | 6 | 6 | $0.004 | $0.006 | | pr947 | 2026-08-28 | 2/2 | 2/2 | 4.5 | 3 | $0.008 | $0.008 | | pr958 | 2026-09-04 | 2/2 | 2/2 | 6 | 4 | $0.012 | $0.012 | | pr959 | 2026-09-11 | 2/2 | 2/2 | 1 | 2 | $0.008 | $0.009 | | pr968 | 2026-09-11 | 2/2 | 2/2 | 1 | 1 | $0.005 | $0.006 | | pr975 | 2026-09-18 | 0/2 | 0/2 | 13.5 | 8 | $0.018 | $0.014 | | pr973 | 2026-09-18 | 2/2 | 2/2 | 2 | 3 | $0.012 | $0.011 | | pr972 | 2026-09-19 | 0/2 | 0/2 | 15.5 | 13 | $0.024 | $0.025 | | pr977 | 2026-09-21 | 2/2 | 2/2 | 2 | 2.5 | $0.012 | $0.011 | | pr976 | 2026-09-21 | 2/2 | 2/2 | 8.5 | 8 | $0.011 | $0.009 | | pr979 | 2026-09-22 | 0/2 | 0/2 | 10 | 5 | $0.042 | $0.030 | | pr980 | 2026-09-23 | 2/2 | 2/2 | 12.5 | 12.5 | $0.009 | $0.011 | | pr981 | 2026-09-25 | 1/2 | 1/2 | 13 | 13.5 | $0.022 | $0.018 | | pr988 | 2026-09-25 | 2/2 | 2/2 | 6 | 6 | $0.015 | $0.016 | | pr989 | 2026-09-25 | 2/2 | 2/2 | 3 | 3 | $0.012 | $0.016 | | pr992 | 2026-10-02 | 0/2 | 0/2 | 4.5 | 4 | $0.012 | $0.012 | | pr1001 | 2026-10-02 | 2/2 | 2/2 | 16.5 | 13 | $0.017 | $0.018 | | pr994 | 2026-10-02 | 2/2 | 2/2 | 3.5 | 3 | $0.012 | $0.011 | | pr997 | 2026-10-09 | 0/2 | 0/2 | 31.5 | 25.5 | $0.018 | $0.022 | | pr1009 | 2026-10-09 | 2/2 | 2/2 | 3 | 2.5 | $0.010 | $0.007 | | pr1010 | 2026-10-09 | 0/2 | 0/2 | 7 | 6.5 | $0.013 | $0.012 | | pr1006 | 2026-10-09 | 0/2 | 0/2 | 17 | 18.5 | $0.021 | $0.019 | | pr1004 | 2026-10-12 | 0/2 | 0/2 | 36 | 27.5 | $0.028 | $0.038 | | pr1008 | 2026-10-12 | 0/2 | 0/2 | 22 | 10.5 | $0.037 | $0.014 | | pr1019 | 2026-10-13 | 2/2 | 2/2 | 6 | 6 | $0.019 | $0.015 | | pr1025 | 2026-10-13 | 0/2 | 0/2 | 30 | 16 | $0.017 | $0.016 | | pr1029 | 2026-10-13 | 2/2 | 2/2 | 25.5 | 18.5 | $0.037 | $0.019 | | pr1022 | 2026-10-13 | 2/2 | 2/2 | 30 | 27 | $0.021 | $0.016 | | pr1033 | 2026-10-14 | 1/2 | 1/2 | 116.5 | 98 | $0.188 | $0.144 | #### w3lib | Task | Merged | Passed: no tool | lessloc | Production lines: no tool | lessloc | Cost: no tool | lessloc | |---|---|--:|--:|--:|--:|--:|--:| | pr264 | 2026-06-15 | 2/2 | 2/2 | 1 | 1 | $0.005 | $0.005 | | pr266 | 2026-06-15 | 2/2 | 2/2 | 1 | 1 | $0.007 | $0.006 | | pr268 | 2026-06-17 | 2/2 | 2/2 | 2 | 2 | $0.009 | $0.007 | | pr271 | 2026-06-22 | 2/2 | 2/2 | 2 | 2 | $0.006 | $0.007 | | pr273 | 2026-07-09 | 2/2 | 2/2 | 1 | 1 | $0.006 | $0.006 | | pr286 | 2026-07-23 | 2/2 | 2/2 | 1 | 1 | $0.009 | $0.008 | | pr288 | 2026-07-24 | 2/2 | 2/2 | 18.5 | 9 | $0.035 | $0.021 | | pr289 | 2026-07-24 | 2/2 | 2/2 | 13.5 | 12.5 | $0.011 | $0.009 | | pr290 | 2026-07-27 | 2/2 | 2/2 | 6.5 | 1.5 | $0.010 | $0.008 | | pr291 | 2026-07-28 | 2/2 | 2/2 | 7 | 7 | $0.007 | $0.005 | | pr292 | 2026-07-29 | 2/2 | 2/2 | 3 | 2 | $0.008 | $0.007 | | pr167 | 2026-08-03 | 0/2 | 0/2 | 18 | 12 | $0.011 | $0.007 | | pr300 | 2026-08-13 | 1/2 | 1/2 | 14.5 | 10.5 | $0.010 | $0.011 | | pr301 | 2026-08-13 | 2/2 | 2/2 | 8 | 1.5 | $0.009 | $0.009 | | pr302 | 2026-08-13 | 2/2 | 2/2 | 2.5 | 1 | $0.009 | $0.007 | | pr293 | 2026-08-13 | 1/2 | 1/2 | 41.5 | 13 | $0.021 | $0.016 | | pr303 | 2026-08-13 | 0/2 | 0/2 | 7.5 | 1.5 | $0.006 | $0.005 | | pr299 | 2026-08-13 | 0/2 | 0/2 | 25.5 | 12 | $0.013 | $0.012 | | pr305 | 2026-08-17 | 2/2 | 2/2 | 7 | 7 | $0.009 | $0.009 | | pr307 | 2026-08-30 | 2/2 | 2/2 | 8.5 | 3.5 | $0.008 | $0.010 | | pr317 | 2026-09-08 | 2/2 | 1/2 | 8.5 | 3.5 | $0.014 | $0.012 | | pr321 | 2026-09-09 | 2/2 | 2/2 | 4.5 | 5 | $0.005 | $0.006 | | pr333 | 2026-09-17 | 0/2 | 0/2 | 111.5 | 85 | $0.023 | $0.014 | | pr332 | 2026-09-17 | 0/2 | 1/2 | 16.5 | 11 | $0.023 | $0.022 | | pr334 | 2026-09-17 | 1/2 | 0/2 | 25.5 | 20 | $0.022 | $0.016 | | pr337 | 2026-09-17 | 0/2 | 0/2 | 16 | 9.5 | $0.013 | $0.014 | | pr338 | 2026-09-18 | 0/2 | 0/2 | 10.5 | 5 | $0.016 | $0.018 | | pr342 | 2026-09-21 | 2/2 | 2/2 | 3.5 | 1.5 | $0.008 | $0.008 | | pr343 | 2026-09-22 | 2/2 | 2/2 | 19 | 15.5 | $0.041 | $0.029 | ### Measures - **Passed:** every hidden test of the task passed after the session. A task's pass rate is the share of its sessions that passed. The difference is counted in tasks: the sum, over tasks, of lessloc's pass rate minus no tool's. - **Production lines:** the non-blank lines a session's change added to the project's Python files outside its tests, comments and docstrings included. **Test lines** are the same count inside them: `tests/`, `test/`, `testing/`, `test_*.py`, `*_test.py` and `conftest.py`. - **Cost per task:** the mean cost of a task's sessions, lessloc's own additions included. A session that runs out of its 20 minutes is priced from the tokens it streamed; in this run, none did. - **Earlier changes broken:** how many of the project's earlier judged changes had hidden tests that failed after the session. In pydantic-settings, pr860 and pr979 show the same earlier changes broken in every session of both setups. - **Intervals:** 95% bootstrap intervals over tasks, paired by task, from 4,000 resamples. Each task counts once, as the mean of its sessions. ### The projects The tasks are the judged changes among each project's first 70 changes to its source after a release: - **starlette** from 1.3.1: 43 tasks, merged from August 5 to October 18, 2026. One more judged change, pr3463, is left out, as planned before the run: its hidden tests need packages the project added after 1.3.1, so in the environment the benchmark builds at the start, even the human's change fails them. - **pydantic-settings** from v2.14.1: 45 tasks, merged from June 3 to October 14, 2026. - **w3lib** from v2.4.1: 29 tasks, merged from June 15 to September 22, 2026. lessloc's checks were designed on other projects, tomlkit and click. pydantic-settings and w3lib were the validation projects: the checks were measured on both, this version was chosen on their results, and the note at a turn's 40th tool call came from long sessions on pydantic-settings. starlette was the held-out project: no version of lessloc was scored or tuned on it before this run. Their project.json files, with `uv` on the PATH: ```json { "repo": "Kludex/starlette", "start": "1.3.1", "branch": "main", "source": ["starlette"], "tests": ["tests"], "max": 70, "setup": "UV_PYTHON=3.12 uv sync -q --frozen --no-default-groups --group dev", "python": ".venv/bin/python" } ``` ```json { "repo": "pydantic/pydantic-settings", "start": "v2.14.1", "branch": "main", "source": ["pydantic_settings"], "tests": ["tests"], "max": 70, "setup": "UV_PYTHON=3.12 uv sync -q --frozen --no-default-groups --group testing --all-extras && uv pip uninstall -q -p .venv/bin/python pytest-pretty", "python": ".venv/bin/python" } ``` ```json { "repo": "scrapy/w3lib", "start": "v2.4.1", "branch": "master", "source": ["w3lib"], "tests": ["tests"], "max": 70, "setup": "uv venv -q --allow-existing -p 3.12 .venv && uv pip install -q -p .venv/bin/python --exclude-newer 2026-10-18T00:00:00Z pytest-timeout -e . pytest && echo 'import os; os.environ.setdefault(\"PYTEST_TIMEOUT\", \"60\")' > .venv/lib/python3.12/site-packages/zz_bench_timeout.pth", "python": ".venv/bin/python" } ``` ## How the benchmark works (`bench/real`) `bench/real` turns a real open-source project's history into tasks. The start is a released version. The tasks are the later changes to its source, in the order each reached the main branch, up to the project's `max` (80 by default); a change of more than 1,000 source lines is skipped. Each task's text is the linked issue, else the pull request. The agents cannot look up the real change. Their copy of the project has no history past the task's start and no remote. In Claude Code they have no web tools. In their shell, the usual ways online fail: `gh`, `curl` and `wget` on the PATH, git remotes, and the pip and uv package indexes. A task is judged by the tests its real change added, which the agent never sees. Those tests are kept only if they fail before the change and pass after it, both in the layout the grader uses. A test that uses a name the change introduced, where the task text never mentions that name, is dropped, as SWE-bench Verified's review demands. A hidden test that a later real change broke on purpose retires at that change. After each task, the hidden tests of every earlier judged task run again. The harness runs tasks in two ways: - **Per-task replays**, which the results use. Each task starts from the project's own code just before its change landed, with no later commit in reach, so no task depends on another, as in SWE-bench. - **Timelines.** One setup's run through the whole history: every task goes to a fresh session, on the code exactly as earlier sessions left it, with no resets, so each setup builds on its own code. Tasks without hidden tests are done too, so the code later sessions build on never misses a step. Timelines also measure code growth: how much the source folder grew, in non-whitespace characters of non-comment lines, so packing code onto fewer lines does not shrink it. ```sh node bench/real/mine.mjs project.json clone > candidates.json # changes and their texts, via the GitHub CLI node bench/real/prepare.mjs candidates.json clone prepared/ # hidden tests, judged or not, retirement node bench/real/run.mjs prepared/ clone --model haiku --dry-run # the start fails every judged task; no commit after it node bench/real/run.mjs prepared/ clone --model haiku --arms none,lessloc --runs 2 --replay list.json # list.json: [{"task": "pr3410"}, ...] python3 bench/real/stats.py prepared/ # the tables above ``` The harness takes Python projects tested with pytest whose history is linear: every change on the main line is a squash commit or a pull-request merge. Pick projects without branch syncs, such as merges from a stable branch. ### Reproducing the results For each project, mine and prepare its tasks from its project.json above, in a full clone with its tags. Then list the project's task ids from [Every task](#every-task) in a file, as `[{"task": "pr3410"}, {"task": "pr3418"}, ...]`, and run: ```sh node bench/real/run.mjs --model haiku --arms none,lessloc --runs 2 --replay python3 bench/real/stats.py ``` Mining fetches each task's text from GitHub as it reads on that day, so an issue edited since this run gives a slightly different task. Keep your candidates.json to rerun the same texts later. - `--model haiku` must be given, because the harness defaults to sonnet. - `--runs` is sessions per task in a replay, and timelines per setup otherwise (default 3). `--jobs` is how many run at once (default 3). - `--tasks ` takes the first n tasks of a timeline; without it, every task runs. - `--replay ` runs single tasks. The file is a JSON list of `{"task": }`, each started from the project's own code just before the task's change landed. It can also list `{"repo": , "task": }`, each started from the state that timeline reached just before the task. - Each arm in `--arms` is one setup: `none`, `lessloc`, `lessloc-no-` (an ablation), or `lessloc-old` (another lessloc checkout, given with `--old `). The first arm is the baseline. - `--host claude` is the default, and `--claude ` picks the Claude Code command. - `--host codex` or `--host opencode` runs the same tasks in Codex or OpenCode. Each timeline, and each replayed task, gets a config home of its own for the tool, so the user's own configuration never leaks in. Codex runs with `CODEX_HOME` set to a fresh folder that holds lessloc's hooks only in lessloc arms; it signs in with `OPENAI_API_KEY` or a link to your `~/.codex/auth.json`. Codex reports tokens but no cost, so `--price ,,` (dollars per million tokens) turns its tokens into one. OpenCode needs `--model /`, runs with a fresh `XDG_CONFIG_HOME`, loads lessloc's bridge plugin only in lessloc arms, and reports its own cost estimate from tokens and the model's list price. - A session cut short by a usage limit, or one that returned no result, runs again. One that runs out of its 20 minutes counts as it ended, priced from the tokens it streamed at what the run's complete sessions paid per input-side token, so it never counts as free. - With [bubblewrap](https://github.com/containers/bubblewrap) installed (`bwrap`), each session sees only its own processes, so an agent's `pkill -f ` cannot end another session, its tests or the run. `LESSLOC_BENCH_SHARED_PIDS=1` turns this off. - `TMPDIR` decides where the results JSON and each timeline's kept repository go, so point it at a disk with room. When uv's cache is on another filesystem, set `UV_LINK_MODE=copy`. - The results JSON holds every session and records the versions (Claude Code, model, lessloc) and which code ran: the harness's commit and a hash of its uncommitted diff, and each lessloc setup's commit, diff hash and `LESSLOC_OFF` switches. Run from a clean checkout, so the diff hash is empty. ## Refusing unfinished work, without a model (`bench/candidates.mjs`) For each judged task of a prepared real project, the human's change is a finished candidate. Variants of it leave work out: the tests, half of the fixed files, a block replaced by a stub, the final test run, or a test run that comes before the last edit. Each candidate is replayed through lessloc's hooks the way an agent would make it, with the request, one write per file, a real run of the project's tests and a final reply that claims they pass. The task's hidden tests say whether its code is finished. A hold on finished work is a false reject; a candidate with work left out that goes through is a false accept. ```sh node bench/candidates.mjs [--tasks pr471,pr486] ``` ## Precision on code humans merged, without a model (`bench/replay.mjs`) Commits humans already merged are the false-positive test. Each one is replayed through lessloc's review as `check --base ` sees it, and through the turn end's check of requested names, with the commit's message as the request. ```sh node bench/replay.mjs [commits=30] [--show copy,untested] [--json ] ``` In full clones at ripgrep 3fce3b5, ky 3541888, cobra adbc881, gson e2292f9 and click 2247b35: - **30 commits each.** `copy` fired 10 times: 8 on code repeated verbatim, and 2 on ripgrep code with the same shape but different fields or values (two builder setters, two flag definitions). The other kinds that stop a turn fired 23 times: `weakened-tests` 9, `untested` 9, `suppression` 3 and `commented-out` 2; these 23 were counted, not hand-labeled. - **200 commits each.** The messages ask for 121 names (ripgrep 18, ky 59, cobra 7, gson 12, click 25), and `unaddressed` would have held none of the 1,000 commits. ## What the hooks add to a session, without a model (`bench/weight.mjs`) One scripted session (session start, a prompt, a search, an edit, a test run, the turn end) is replayed through a lessloc checkout's hooks on a throwaway clone of a repository. It prints each event's time and the tokens it adds to the agent's context. ```sh node bench/weight.mjs --file --prompt [--lessloc ] [--test ] [--runs 5] [--show] ``` ## Scope The results cover three Python projects, all with Haiku in Claude Code. `--host` and `--model` run the same tasks in Codex or OpenCode and with other models. Correctness is judged by the hidden tests of each human change.