# Auto-lint: bake `lint.md` into agent workflows

> **Superseded (2026-06):** the single `infra/lint.md` was split into
> `infra/lint/` (a shared `shared.md` harness + coarse lane files), and Layer 2
> became a multi-lane fan-out (one agent per lane) with an agent composer plus
> advisory static-complexity leads. This doc is kept as the original design
> record; paths named `infra/lint.md` below now live under `infra/lint/`.

**Status:** Layer 2 (the `--review` command) implemented; Layers 1 and 3b are follow-ups
**Date:** 2026-05-20
**Goal:** Agents self-apply the `infra/lint.md` rule catalog before pushing, so
humans stop repeating the same nits ("remove the local import", "this comment
restates the code") on every PR.

## Background

- `infra/lint.md` (#5843) is a self-contained catalog of ~50 `ml-...` rules:
  condition, why, when-allowed, bad example, plus a "Detector usage" section
  that already specifies inputs, confidence thresholds, and an output format
  (`path:line: code (conf) message`). It is written to be run by an agent.
- `infra/pre-commit.py` runs the mechanical/offline checks (ruff, black,
  pyrefly, license headers, ...). It is the mandated lint entry point.
- The `commit` and `author-pr` skills already call `pre-commit.py`; nothing
  applies `lint.md` yet.
- #5843's commit message states the intent directly: "wire something up so
  this is called by the current agent as a sub-agent process and nits are
  handled automatically."

## Architecture: three layers

`lint.md` rules split by how they are best decided.

### Layer 1 — Mechanical (deterministic, fast, free, blocking)

Rules decidable without an LLM. Two sub-cases.

**1a. Covered by ruff** — the headline win is `ml-local-import`, which is
exactly ruff's `PLC0415` (`import-outside-top-level`). This is the #1 nit the
user never wants to repeat.

- Caveat: `ruff check --select PLC0415 lib/` reports **696 existing
  violations**. Enabling it blocking, repo-wide, immediately would block almost
  every PR that touches `lib/`.
- Recommended path: (1) a one-time burn-down PR — use the new agentic fixer
  (Layer 2) to lift the 696 local imports, keeping `# noqa: PLC0415` only on
  the genuine optional-dependency cases paired with their `try/except
  ImportError`; (2) then add `"PLC0415"` to `select` in the root,
  `lib/levanter`, and `lib/haliax` `pyproject.toml` ruff configs so it stays at
  zero. Until the burn-down lands, the diff-scoped check below covers new code.

**1b. Mechanical but not in ruff** — ~3 small custom detectors. The repo is
already largely compliant (these are explicit architecture rules), so they can
block:

- `ml-reverse-layer-import` / `ml-cross-sibling-import`: AST-scan imports under
  `lib/<x>/src/**`, flag forbidden layer crossings
  (`{iris,haliax} → {levanter,zephyr} → marin`).
- `ml-type-checking-guard`: flag `if TYPE_CHECKING:` blocks. AGENTS.md already
  forbids them outright.
- `ml-utils-module`: new files named `*_utils.py` / `*_helpers.py`.

**Diff-scoping:** every Layer 1 check flags only added/modified lines in the
branch diff, never pre-existing code — consistent with `lint.md`'s detector
section. This is what makes rollout safe despite the 696 backlog.

### Layer 2 — Agentic (LLM, judgement, advisory)

Everything else in `lint.md` (comments, defensive code, dead code, duplication,
naming judgement, test quality). `lint.md` is already the detector prompt.

`run_lint_review()` in `infra/pre-commit.py`, invoked via the new `--review`
flag:

1. Compute the diff: working tree vs `merge-base origin/main HEAD`, for
   `*.py` / `*.proto`, with `-U15` context. This covers all branch work,
   committed and uncommitted, so the review runs whether or not the author
   committed before the pre-push checklist.
2. Build the prompt = `infra/lint.md` contents + the diff.
3. Invoke a headless agent — `claude -p` by default, overridable via the
   `--agent-command` CLI flag (skills instruct the calling agent to pass its
   own headless command, e.g. `--agent-command='codex exec'`). The subprocess
   env strips `ANTHROPIC_API_KEY` (forces subscription auth, not metered API
   billing) and `CLAUDECODE`/`CLAUDE_CODE_*` (so the sub-agent runs as a fresh
   session rather than nesting under the calling agent).
4. Print captured stdout. `lint.md` mandates the one-finding-per-line format.
5. Findings are **advisory** — `--review` prints them and exits 0 (it exits 1
   only when the review could not run). `lint.md` is explicit: "advisory —
   never block."

Diff-size handling: v1 passes the whole branch diff in one call; if it exceeds
a threshold, fall back to `lint.md`'s prescribed per-file second pass.

**Why a subprocess, not the Claude Code Task tool:** one mechanism that behaves
identically whether called from a skill, a git hook, or CI. The headless agent
sees only `lint.md` + the diff, so it is focused and isolated from the calling
agent's context.

### Layer 3 — Wiring it into every workflow ("defaulted in")

- `commit` skill: add a pre-push step — run `./infra/pre-commit.py --review`,
  fix findings, re-run once to confirm, then stop (no infinite loop).
- `author-pr` skill: add `./infra/pre-commit.py --review` to the Pre-Push
  Checklist.
- `review-pr` skill (optional): add `lint.md` as a 5th reviewer agent — closes
  the loop on the reviewer side as a backstop. Author-side is the priority.
- Git `pre-push` hook (optional): a committed sample + installer, runs
  `--review` advisory so non-agent pushes get the same feedback. Tradeoff:
  ~1-2 min latency + tokens on every `git push`.
- `AGENTS.md`: one line in the Development section pointing at `--review`.

## Advisory vs blocking

Keep the **agentic** layer advisory — it has false positives, and `lint.md`
itself says blocking erodes trust. Keep the **mechanical** layer blocking — it
is deterministic with no false positives. The automation is that the *skills*
instruct the agent to fix advisory findings; agents reliably follow skills, so
"advisory" still gets cleaned up in practice.

## Phase 3b — Growing the catalog from feedback

"Collect feedback from previous agent interactions to build rules": a periodic
job (new skill `distill-lint-rules`, or folded into
`scrub-reflection-self-improvement`):

- Pull review comments from recently-merged PRs (especially `agent-generated`).
- Cluster recurring stylistic nits not already covered by an `ml-` rule.
- Propose new rules as a PR editing `lint.md` in the existing rule structure.

This keeps `lint.md` a living catalog rather than a one-time write.

## Rollout sequence

1. **[done]** Layer 2: `pre-commit.py --review` runs the catalog over the
   branch diff via a headless agent. Advisory, standalone, no rollout risk.
2. **[done]** Wire `--review` into the `author-pr` Pre-Push Checklist;
   `# noqa: PLC0415` suppression marker added to `lint.md`.
3. Layer 1b custom checks (reverse-layer, `TYPE_CHECKING`, `*_utils.py`) —
   diff-scoped, blocking.
4. One-time `PLC0415` burn-down PR (696 violations) using `--review`, then
   enable `PLC0415` in the root, `lib/levanter`, and `lib/haliax` ruff configs.
5. Optionally add `lint.md` as a reviewer agent in `review-pr` (backstop).
6. Phase 3b feedback loop.

## Decisions (resolved 2026-05-20)

1. **Cost vs coverage:** review runs **pre-push only** — wired into `author-pr`,
   not `commit`. `/commit` stays fast for WIP; the branch-diff scope means one
   review at PR time covers all commits anyway.
2. **Pre-push hook:** **no repo-wide git hook** — agent skills only.
3. **`PLC0415` backlog:** a dedicated burn-down PR will clear the 696; because
   `--review` is advisory and diff-scoped, acceptable local imports are marked
   with `# noqa: PLC0415` (ruff convention) — honored by both `--review` and,
   post-burndown, ruff itself.
4. **Agent CLI:** `claude -p` as the default; overridden per-call via the
   `--agent-command` flag so the calling agent passes its own headless
   invocation. The subprocess unsets `ANTHROPIC_API_KEY` (subscription auth)
   and `CLAUDECODE`/`CLAUDE_CODE_*` (prevents the spawned agent from inheriting
   the parent's session).
