Making Local CI Commands Boring Enough for Humans and AI Agents

Published · Updated · Programming

Local CI commands should be boring.

That sounds like faint praise, but boring is exactly what you want from the command that tells a human developer, a coding agent, or a pull request bot whether the repository is healthy enough to trust.

The problem is that many repositories make this surprisingly hard. Tests live behind tribal knowledge. Formatting is half automatic and half a wiki page. Linting works in CI but not on laptops. The "real" command is hidden in a YAML file, except for the one service that needs a special environment variable, and the one old package that cannot run on Apple Silicon unless you know the workaround.

That is annoying for humans. It is worse for AI coding agents.

An agent can read files, infer patterns, and try commands. But if the repository does not provide a stable local interface for validation, the agent ends up doing what a new engineer does: guessing. It runs the closest-looking test command, misses the lint step, formats the wrong subtree, or gives up because the first failure had nothing to do with the change.

This is the practical next layer after Bazel vs. Make vs. Just: Choosing Build Tools for Real Engineering Teams, Designing Guardrails for AI-Generated Pull Requests, and Reviewing AI-Written Tests Without Fooling Yourself. Good review discipline matters. So does good build tooling. But if the local feedback loop is vague, both humans and agents spend too much time interpreting the ceremony around the work instead of validating the work itself.

The Command Is Part Of The Product

Every serious repository should have a small set of local commands that behave like product interfaces:

  • test
  • lint
  • format
  • check
  • ci

The exact spelling can vary. Maybe the interface is make test. Maybe it is just test, task test, npm test, uv run pytest, bazel test //..., or a thin script in tools/. The important part is that the command is intentional, documented, and stable enough that people can build habits around it.

That stability matters because local CI commands serve multiple audiences:

  • Engineers checking work before opening a pull request.
  • Reviewers reproducing failures.
  • New hires learning the repository.
  • Release engineers validating patches.
  • AI coding agents trying to prove a change is not obviously broken.
  • Future maintainers who do not remember why the CI YAML looks the way it does.

If your command interface changes every time the CI provider changes, it is not a repository interface. It is just leakage from the build system.

I like treating these commands the same way I treat public function names. They do not have to be perfect forever, but changing them should require a reason.

Start With The Small Contract

A useful local CI surface does not need to mirror every CI job. In fact, trying to reproduce the entire hosted CI system locally is often how teams make the problem worse.

Start with a small contract:

Command Contract
make test Run the normal fast test suite for local development.
make lint Report style, static analysis, and policy failures without rewriting files.
make format Rewrite files into the repository's expected format.
make check Run the usual pre-PR validation path.
make ci Run the closest practical local equivalent of required CI checks.

Those commands can delegate to whatever the repository actually uses. A Python project might call uv run pytest, ruff check, and ruff format. A frontend project might call package manager scripts. A Bazel repo might wrap bazel test targets with the flags the team expects. A polyglot monorepo might route to several tools.

The wrapper is not there to hide the truth. It is there to give the user a stable entrance.

That is especially helpful when the underlying tool changes. If the repo moves from black to ruff format, the local habit can remain make format. If a test target splits into shards, the local contract can remain make test. If the CI provider changes, the developer interface does not need to churn.

Fast Feedback And Full Confidence Are Different Jobs

One of the most common mistakes is forcing one command to do two incompatible things.

Developers need fast feedback while they are working. CI needs enough coverage to protect the branch. Those are related but not identical.

If make test takes 45 minutes, developers will not run it often. If make ci takes 90 seconds but skips important integration failures, the team will stop trusting it. The answer is not one magical command. The answer is clear layering.

A practical structure looks like this:

.PHONY: test lint format check ci

test:
    uv run pytest tests/unit

lint:
    uv run ruff check .

format:
    uv run ruff format .

check: lint test

ci:
    uv run ruff check .
    uv run pytest

That is intentionally simple. Real repositories may need more nuance, but the principle holds:

  • test should be the default local test loop.
  • check should be the common pre-PR command.
  • ci should be slower and more complete.
  • Long-running integration, security, browser, or deployment checks should be named clearly instead of hiding inside a surprising default.

For AI agents, that distinction is gold. An agent can run make check after a small edit without burning time on every expensive path. When the change is broader, it can run make ci and report the slower result. The repository gives the agent a map instead of making it infer intent from filenames.

Make Failure Output Useful

A local CI command is not only a command. It is also a diagnostic interface.

When it fails, the output should answer three questions quickly:

  • Which command failed?
  • What should I inspect next?
  • Is the failure likely related to my change?

This is where shell cleverness often works against teams. A giant script that prints a wall of output, swallows exit codes, and ends with "something failed" is not a validation system. It is a small fog machine.

Prefer boring behavior:

  • Echo major phases before running them.
  • Preserve the failing tool's exit code.
  • Avoid hiding output unless the replacement summary is genuinely better.
  • Put generated reports in predictable locations.
  • Do not continue past a required failing step unless the command is explicitly collecting all failures.

For example:

#!/bin/sh
set -eu

echo "==> lint"
uv run ruff check .

echo "==> unit tests"
uv run pytest tests/unit

That script is not glamorous. It is easy to debug at 5:30 p.m. on a Friday, which is usually more important.

If a command intentionally runs multiple independent checks and reports all failures, make that explicit. A check-all command that continues after a failure can be useful. A ci command that masks the first real failure is not.

Keep Formatting Separate From Checking

Formatting deserves its own small bit of discipline.

I prefer separate commands:

  • make format rewrites files.
  • make lint or make format-check reports formatting drift without rewriting.
  • make check uses the non-mutating version.

That split matters for humans because nobody wants a validation command to silently rewrite unrelated files. It matters even more for agents because it keeps intent clear. If an agent is asked to make a small code change, a non-mutating check can show whether formatting is needed. A later explicit format step can be reported as part of the change.

This also keeps CI honest. CI should not fix formatting. CI should fail and tell the contributor what command to run.

A good failure message is blunt and helpful:

Formatting check failed.
Run: make format

That is not fancy. It saves review time.

Do Not Make Developers Read CI YAML

CI YAML is not documentation. It is executable configuration for a remote system. It can contain useful truth, but it is a lousy primary interface for day-to-day development.

When the only way to know the real test command is to read .github/workflows, .buildkite, .circleci, or some internal pipeline definition, the repository has already leaked too much operational detail into the developer workflow.

The better pattern is inversion:

  • Local commands live in Makefile, justfile, Taskfile.yml, package scripts, or tools/.
  • CI calls those same commands.
  • CI adds orchestration, secrets, caches, matrix expansion, and publishing.
  • The core validation behavior remains reachable locally.

That makes the local command the source of truth for "how do I validate this repo?" CI becomes one caller of that interface, not the only place the interface exists.

This also reduces the gap between local and remote failures. If CI runs make ci, a developer or agent can run the same command before pushing. There will still be environment differences. Hosted runners, containers, secrets, permissions, and platform-specific behavior do not disappear. But the first question becomes much simpler:

Did the same command pass locally?

That is a much better debugging starting point than "which CI incantation did the pipeline assemble today?"

Design For Fresh Machines

A local CI command should assume it may be run by a machine that does not have your personal setup.

That does not mean every command must bootstrap the entire universe from scratch. It does mean failures should be obvious and recoverable.

Check for common prerequisites:

  • Language runtime.
  • Package manager.
  • Tool version manager.
  • Docker or container runtime, if required.
  • Credentials, only when genuinely needed.
  • Generated files or dependency sync steps.

If a missing dependency is expected, say so:

missing: uv
Install with: brew install uv

That is better than letting the shell fail with a command-not-found error from three layers down.

For agent workflows, fresh-machine behavior matters because agents often work in clean or semi-clean environments. They may not have your shell aliases, global packages, editor plugins, or hand-tuned PATH. A command that works only because a senior engineer's laptop has accumulated five years of setup sediment is not a real local CI command.

Make The Agent Path Explicit

You do not need separate AI-only commands in most repositories. You do need commands whose purpose is obvious enough that an agent can choose correctly.

Good names beat clever names:

  • make check
  • make test
  • make lint
  • make format
  • make ci

The README should contain a small "Local validation" section:

## Local Validation

- `make check`: run the normal pre-PR checks.
- `make test`: run the fast test suite.
- `make ci`: run the full local CI equivalent.
- `make format`: apply formatting.

That section helps humans. It also helps agents because it gives them a high-confidence instruction they can follow and cite in their final report.

If the repository has known expensive checks, document them:

`make integration-test` requires Docker and takes about 10 minutes.
Run it when changing database, queue, or API boundary behavior.

That kind of detail prevents both under-testing and wasteful testing. An agent working on a CSS typo does not need to run a full integration suite. An agent touching persistence logic probably does.

Version The Contract With The Code

Do not rely on a wiki page for the commands that validate the repository. Keep the contract in version control and make it part of code review.

That gives you a few useful properties:

  • Changes to validation commands are reviewed like code.
  • Old branches carry the command set that matched their code.
  • CI and local development can evolve together.
  • Agents can inspect the command interface directly.

It also creates accountability. If a team adds a new required lint check in CI, the local command should change in the same pull request or very soon after. If a test suite is split, the wrapper should keep the common path easy to run. If a tool upgrade changes flags, the command interface should absorb as much churn as reasonable.

The command surface is not glamorous infrastructure, but it is infrastructure. Someone owns it, or it rots.

The Boring Local CI Checklist

When I look at a repository's local validation setup, this is the checklist I want to pass:

  • There is one obvious pre-PR command.
  • CI calls the same local command where practical.
  • Fast and full checks have different names.
  • Formatting can be applied intentionally.
  • Check commands do not rewrite files unexpectedly.
  • Exit codes are preserved.
  • Failure output names the failed phase.
  • Required tools are pinned or checked.
  • Setup instructions are short and current.
  • The commands work on a reasonably fresh machine.
  • Expensive or environment-dependent checks are named explicitly.
  • The README tells humans and agents what to run.

If that sounds basic, good. Basic is the point.

Complicated validation systems are sometimes necessary. Complicated developer interfaces usually are not.

Conclusion

The best local CI commands are not impressive. They are dependable.

They let a human developer make a change, run a small number of obvious commands, and understand the result. They let CI reuse the same repository interface instead of becoming a parallel universe. They let AI coding agents do the responsible thing without guessing which script matters this week.

That is why boring local commands are a real developer productivity investment. They reduce friction, shorten review loops, and make correctness easier to check before the pull request turns into archaeology.

If your repository does not have this yet, start small. Add make check. Document it. Make CI call it. Then refine from there.

The goal is not to build a majestic validation framework. The goal is to make the right command so obvious that humans and agents both stop having to ask.

For more practical engineering workflow pieces, visit Slaptijack.

Slaptijack's Koding Kraken