ai features
Lintro includes optional AI-powered features that provide actionable insights and interactive fix suggestions on top of standard linting results. > **Requirements:** Python package extra + an API key.
AI-Powered Features
Lintro includes optional AI-powered features that provide actionable insights and interactive fix suggestions on top of standard linting results.
Requirements: Python package extra + an API key.
pip install -e '.[ai]' # Set the API-key variable your chosen provider declares — see "Providers". export ANTHROPIC_API_KEY=sk-ant-... # anthropic export CURSOR_API_KEY=... # cursor export OPENAI_API_KEY=sk-... # openai
Quick Start
# Enable AI in your config
# .lintro-config.yaml
# ai:
# enabled: true
# provider: anthropic | cursor | openai # required, no default
# transport: api | cli # no default; doctor flags it unset
# Run check — AI summary is generated automatically (1 API call)
lintro check
# Add interactive fix suggestions
lintro check --fix
# Auto-fix with AI post-fix summary
lintro format
Features Overview
AI Summary (default with check / chk)
When AI is enabled, every lintro check run generates a single-call AI summary that
provides:
- Overview — high-level assessment of code quality
- Key patterns — systemic issues, not individual occurrences
- Priority actions — ordered by impact (fixes that resolve the most issues first)
- Estimated effort — rough time estimate to address all issues
This costs one API call regardless of how many issues exist.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
AI SUMMARY — actionable insights
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Code quality is generally good but 12 type annotation issues
in src/utils/ suggest a systematic gap in utility functions.
Key Patterns:
• Missing type annotations in 8 utility functions in src/utils/
• Unused imports in 4 test files (likely copy-paste artifacts)
Priority Actions:
1. Add return type annotations to src/utils/ (resolves 8 issues)
2. Remove unused imports in test files (resolves 4 issues)
Estimated effort: 20-30 minutes of focused cleanup
Interactive Fix Suggestions (--fix)
The --fix flag generates AI-powered code diffs and presents them for interactive
review:
lintro check --fix
For each group of issues, you’re prompted:
[y]accept group / [a]accept group + remaining / [r]reject / [d]iffs / [s]kip / [v]validate-after-group / [q]quit
- Accept group — applies only the current group
- Accept group + remaining — applies current group, then auto-accepts the rest
- Reject — skips this group
- Diffs — shows the unified diff before deciding
- Skip — moves to the next group
- Validate-after-group — toggles immediate tool validation after each accepted group (does not accept/apply fixes by itself)
- Quit — stops the review
Each group now includes:
- Risk label —
safe-stylevsbehavioral-risk(classified by the AI model) - Patch stats — files touched,
+/-lines, and hunk count
Risk classification is AI-driven: the model self-reports whether each fix is purely
cosmetic (safe-style) or affects behavior (behavioral-risk). Unknown or empty
classifications default to behavioral-risk for safety.
For safe-style groups, pressing Enter defaults to accepting the group.
After the review session, a post-fix AI summary contextualizes what was fixed and what remains.
Git Checkpoints & Rollback
Before an AI fix batch mutates files, lintro captures a snapshot under
refs/lintro/checkpoints/<run-id> using git plumbing on a temporary index
(GIT_INDEX_FILE). This does not touch your index, stash, or HEAD.
- Rollback restores only the files lintro targeted from that checkpoint tree. Every blob is read before anything is written, each file is replaced atomically and keeps its mode, and paths that were not part of the snapshot are never touched.
- Diff against the checkpoint shows how the targeted files differ from their
pre-batch state. When a run changed anything, lintro prints the ref so you can run
git diff <ref>orgit restore --source=<ref> --worktree -- <path>after it exits.restore --worktreeis deliberate:git checkout <ref> -- <path>would also rewrite your index. This is a diff of the files, not an audit trail of lintro alone — an edit you made to a targeted file after capture appears in it too. - Interactive reject restores rejected files from the checkpoint tree, not from in-memory copies. Files an earlier accepted group already changed are left alone, so rejecting one group never discards fixes you just accepted.
- Retention keeps the last N checkpoint refs (default 10), pruning older ones via
git update-ref -dbefore the new ref is written.0keeps only the current run’s checkpoint. - Fallback: outside a git work tree (or in a bare repo), lintro falls back to
file-content snapshots / the legacy reverse patch under
.lintro-cache/ai.
Optional: set ai.checkpoint_fmt: true to capture the same style of checkpoint before
lintro format mutates files. This one is git-only — an in-process snapshot would not
outlive the run, so nothing is captured outside a git work tree.
Semantics: Rolling back a lintro target overwrites that file with the pre-batch snapshot — including any edits you made to that same file between capture and rollback. Non-target files and your staged/unstaged index state are left alone.
AI Fixes in format
When running lintro format, tools auto-fix what they can. For remaining unfixable
issues, the AI generates fix suggestions and presents them interactively (same UX as
--fix in check). After the session, a post-fix summary wraps up what was
accomplished.
Advisory AI finders (idiom-review)
Unlike the AI summary and --fix flows (where AI explains or fixes issues that
linters found), an AI finder uses AI to find issues that syntax-matching linters
structurally cannot. idiom-review is the first one. It has no external binary — it
runs through the existing AI provider abstraction, respecting the same retry, fallback,
and cost-budget controls.
Migration (#1308). AI finders used to run under
lintro chk. They now run underlintro reviewonly. Every tool declares an execution class —deterministic(all classic linters) oradvisory(AI finders) — andchkruns deterministic tools exclusively. Runninglintro chk --tools idiom-reviewis now an error that points at the review verb.# before lintro chk --tools idiom-review # now (both forms still require the tool's own `enabled` opt-in, from # `tools.idiom-review` config or `--tool-options`) lintro review --advisory-only --advisory-tools idiom-reviewWhy: advisory findings are opinions produced by a nondeterministic model. Letting them share
chkmeant two identical runs could disagree, its reported issue counts could move on model mood rather than on regressions, and every contributor paid API latency and dollars on a command meant to be reflexive and offline. Yourtools.idiom-reviewconfig section is unchanged — only the invoking verb moved.
Advisory tools under lintro review:
| Flag | Meaning |
|---|---|
| (default) | Advisory tools run alongside the diff review, over the changed files |
--advisory-tools <names> | Comma-separated advisory tools, all (default), or none |
--advisory-only | Skip the diff review; scan --path values (default .) |
--fail-on-findings | Exit 1 when advisory tools report findings (default: exit 0) |
--tool-options | tool:option=value overrides, as in chk |
Advisory findings never affect the exit code unless --fail-on-findings is passed, and
they never contribute to the chk issue counts. One exception: a round short-circuited
by the convergence stop rule returns
before the advisory tools run at all, so --fail-on-findings is inert on that
invocation. With --output json, they appear under an additive advisory key so
existing consumers of the review JSON keep working.
idiom-review offers two modes:
- per-file (Mode 1) — flags idiomatic misses: code that is correct but verbose,
e.g.
found = False; for x in items: ...instead ofany(cond for x in items). - duplication (Mode 2) — flags the same utility logic reimplemented across files, invisible to any per-file linter, with a suggested extraction point.
The tool ships disabled by default and is a no-op until you opt in. Findings are
cached by a content hash under .lintro-cache/idiom, so unchanged files cost nothing on
repeat runs. When no AI provider is available (missing SDK, key, or credits), the tool
degrades gracefully to a skipped result rather than failing the run.
# .lintro-config.yaml
ai:
enabled: true
provider: anthropic # or cursor / openai — required, no default
transport: api # or cli — no default; doctor flags it unset
tools:
idiom-review:
options:
enabled: true # opt-in gate (default: false)
mode: per-file # per-file | duplication | both
min_confidence: medium # drop findings below this confidence
max_files: 25 # cap files reviewed per run (cost bound)
Or enable it ad hoc from the CLI:
lintro review --advisory-only --tool-options idiom-review:enabled=true
Custom Review Agents (.lintro/review-agents/*.md)
lintro review ships a built-in checklist corpus, but house rules (“no raw SQL outside
the repository layer”, “every Effect service follows X”) are prose, not YAML. Write them
as markdown files under .lintro/review-agents/ — YAML front matter carries the
machine-readable scope and policy, and the body carries the review instruction.
---
name: no-raw-sql
description: SQL must go through the repository layer
include:
- 'src/**/*.py'
exclude:
- 'src/repositories/**'
severity: high
strictness: focused
model: default
enabled: true
---
Review the changed code for raw SQL strings executed outside the repository layer. Flag
any direct `cursor.execute` / `connection.execute` call with a string literal, and point
at the repository method that should be used instead.
Front-matter fields:
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique agent id; becomes the source attribution on findings |
include | list[str] | required | Globs selecting the changed files the agent reviews |
description | string | "" | One-line summary shown by --list-agents |
exclude | list[str] | [] | Globs removing files from the include set |
severity | string | P2 | P1/P2/P3 or high/medium/low; applied to all findings |
strictness | string | balanced | focused · balanced · thorough |
model | string | default | Optional per-agent model override |
enabled | bool | true | Set false to keep the file but stop running it |
Behavior:
- Agents are enabled by default. Control them with
review.custom_agentsin.lintro-config.yaml:true(run alongside the built-in checklist),false(skip discovery entirely), oronly(run agents instead of the built-in checklist). - An agent runs only when at least one changed file matches its
includeglobs afterexcludeis applied. Agents that match nothing — and agents withenabled: false— are reported as skipped and cost nothing. - Findings merge into the normal terminal, JSON, and PR-comment output, attributed with
source: <agent-name>. The agent’s declaredseverityis the severity its findings carry, so aseverity: highagent produces P1 findings that fail the exit gate. - Each scoped agent is one extra provider call and counts against
ai.max_cost_usdexactly like a built-in checklist chunk. - A file with invalid front matter is reported with the offending field and skipped — never fatal, and the rest of the review still runs.
Safety: agent bodies are maintainer-authored workspace content, so they are treated as untrusted data. A body never becomes the system prompt; it is redacted for secrets, sanitized, and embedded in the user prompt inside a per-call unique boundary marker with explicit instructions that nothing inside it can change the model’s role or output contract.
List what would run without spending anything:
lintro review --list-agents
# .lintro-config.yaml
review:
custom_agents: true # true | false | only
Addressed lifecycle on inline threads
When lintro review --post runs again on a PR, every finding the new round no longer
reproduces is stamped on its own inline comment rather than only in the sticky summary:
- Addressed — the comment gets a
✔ Addressed in <sha> · round Nbanner, its copy-paste agent prompt is retitled(historical), and the thread is resolved whenreview.auto_resolveis true — which it is by default; set it tofalseto opt out and resolve the thread by hand. - Partially addressed — a finding reported at several locations resolves only when
the whole pattern is gone. Progress shows as
✔ 14/20 addressed in <sha> · round Nand the thread stays open. - Regressed — a finding that comes back is re-raised on a fresh thread carrying
regression · first raised round X, fixed round Yplus a link to the original. The old thread is stamped↩ Regressed in <sha>and is never reopened.
Thread resolution is the only configurable half; the banner is always written.
# .lintro-config.yaml
review:
auto_resolve: true # default; set false to resolve threads by hand
Confidence gate on inline posting
Every finding carries a model-reported confidence (high, medium, low) and a
kind (finding or question). lintro review --post opens an inline thread only for
findings that clear the posting policy; the rest become notes:
- Inline —
confidenceat or aboveai.review_inline_min_confidence(defaultmedium), andkind: findingunlessai.review_post_questions_inlineistrue, in which case questions that clear the same floor open threads too. These open threads, count in the review body’s header, feed the derived verdict, and are tracked across rounds. - Notes — everything else:
lowconfidence findings, and questions unlessai.review_post_questions_inlineistrue. Notes render in the sticky comment under a collapsed 💬 Notes and questions (N) block, each linked to itsfile:lineat the reviewed commit. They open no thread, are excluded from the verdict, the severity tiles, the open-findings table and the fix prompts, and open no record of their own. Notes are round-scoped, like the summary and the reasoning block: nothing persists them, so a sticky comment re-rendered from state alone — a converged skip, an error surface — shows no notes block. The comment posted by the round that routed them still carries it.
A note never resolves an existing thread. When a finding posted inline in an earlier round comes back as a note, its record is carried forward open — the model still asserts it, only below the floor, or as a question the policy does not post — so the thread stays, the round does not count it as fixed, and the notes entry is tagged with the reason: (below the inline confidence floor this round) for a finding, (questions are not posted inline) for a question. Notes are paired to prior records one at a time, so a sibling at another line that stopped being reported still resolves. The record resolves only once the finding stops being reported at all, and a finding whose confidence recovers matches the record it already had rather than reappearing as new.
Nothing is dropped. JSON and MCP output keep every finding and add posted_inline
(true / false) per finding so a consumer can tell a thread from a note. The terminal
verdict, the JSON readiness_verdict, and the exit code are derived from tracked
records rather than from what posted inline: a P3 opens a record and moves the verdict
to nits only even though it renders in the sticky, while a finding routed to notes
opens no record and never moves it — so a P1 routed to notes does not fail the process.
With the default floor that means a low confidence P1; with
review_inline_min_confidence: low no confidence is below the floor, so every P1
finding blocks again.
# .lintro-config.yaml
ai:
review_inline_min_confidence: medium # low | medium | high
review_post_questions_inline: false # true opens a thread per question
Set review_inline_min_confidence: low to restore posting every kind: finding entry
inline. Questions stay in the notes block regardless of the floor — set
review_post_questions_inline: true to open threads for them too. The two keys are
independent; restoring the pre-#2572 behaviour of a thread per entry takes both.
Posting tiers: P1/P2 inline, P3 in the sticky
On top of the confidence gate, findings are tiered by severity for posting. Only P1
and P2 findings that clear the policy open inline threads. P3 findings never open
a thread: they are listed in the sticky comment under a collapsed 🟡 N P3 nits (not
posted inline) block directly below the round’s Δ table, each row carrying the title,
a compressed description, the fix when there is one, and the location, so the sticky is
a complete surface for a nit. The tier is rendering only: a P3 is still a tracked
record, still counts toward the derived verdict (nits only), still appears in the fix
prompts, and still shows as ✔ fixed in the Δ table when a later round stops
reporting it. The review body’s N findings posted header counts threads, so it
excludes P3s as it excludes notes. There is no setting for the boundary; it is the
constant INLINE_SEVERITIES in lintro/ai/review/posting_tiers.py.
When inline comments cannot be posted
A finding always has a surface. When GitHub refuses the inline review batch — or a finding anchors to a line that is not in the PR’s diff — the affected findings are folded into the sticky comment in full, and the sticky says which of the two happened:
GitHub rate limit (HTTP <status>)— the token was throttled: any HTTP 429, or a 403 whose message names a rate limit (primary API quota or the secondary content-creation limit); the next round retries posting the comments and may be throttled again.some findings map to no line in this PR's diff (HTTP 422)— GitHub rejected a comment’s anchor (a 422 whose errors namelineorposition). The same phrase without anHTTPsuffix marks findings lintro itself could not map to a diff line before posting; no request was rejected in that case.this token is not permitted to post reviews on this PR (HTTP 403)— a 401 or 403 whose message names no rate limit; the status shown is the one GitHub returned.the inline review comments could not be posted— anything else, including a request that never reached GitHub.
The cause is classified from the status and message GitHub actually returned, so a throttled round is never reported as a diff-mapping problem. When GitHub rejects the inline POST, the CI job summary reads the same classification and says the findings reached the sticky comment only, instead of claiming they were posted inline. Exit codes are unaffected: the review still ran.
Suggested-patch validation
A GitHub suggestion block is a one-click commit, so every one is checked against the
real file at HEAD before any surface renders it — the terminal panels, the JSON payload,
and --post alike. The file is read at the head revision (locally, or through gh in
--pr mode); the PR tree is never checked out or executed.
- Exact match — the suggestion is posted as-is.
- Drifted but unique — the change’s
beforeblock occurs exactly once elsewhere in the file, so the hunk and the comment anchor are re-anchored to it. - Anything else — the one-click patch is withheld. The finding keeps its prose and
its described
fix, and is tagged with why:stale_anchor(the block is not there),ambiguous_anchor(it is there more than once), orfile_missing(the file is unreadable at HEAD).
Drops are never silent. Terminal output marks each affected finding and prints a run
total, --output json carries suggestions_dropped plus a
suggestions_dropped_by_reason tally alongside a per-finding suggestion_dropped tag,
and the sticky comment states the count and reasons.
Linter facts in the review prompt
The review protocol puts facts before opinion: the model is given the deterministic
linter results for the changed files before it reads the diff. Two flags feed them.
--with-lint runs the check tools in-process on the working tree, which is right when
the tree is the revision under review (a local branch or --uncommitted).
--lint-report PATH reads a saved lintro JSON report instead
(lintro chk --output-format json, or the .lintro/artifacts/json/results.json side
channel CI emits) and is the form the dogfood review uses: the trusted --pr job checks
out the base branch and never executes PR code, so the untrusted docker-ci lint job
lints the PR head, uploads the report as the linting-json-report artifact, and the
review job downloads it for the exact head SHA it is reviewing. Either way the digest is
restricted to the changed files, size-capped, fenced into the prompt as untrusted data
alongside the diff, and never acted on by lintro itself. A report that is missing,
oversized, or malformed is not fatal: the review runs from the diff alone and the posted
header says linter facts unavailable for this head. The two flags are mutually
exclusive.
Review coverage completeness
There is no per-call findings cap (lintro-ops milestone 0, decision A): a chunk
reports every finding it has, on every transport. What can still reduce a run’s finding
depth is recorded and surfaced rather than left silent. Under --transport cli, a chunk
whose answer exhausts the provider’s output-token ceiling is split by file into two
halves that are each reviewed once (a single-file chunk is retried once unchanged);
unless one half fails (split_half_failed below), every file is still reviewed, but the
model never saw that chunk in one view, so findings that need the whole chunk in view
may go unreported.
ReviewMetadata.coverage_degradationsholds oneCoverageDegradationper limit event, each with areason(output_exhaustion_retried,split_half_failedwhen one half of a split chunk failed and the files in that half were left unreviewed,diff_truncatedwhen a single file’s diff exceeded the context window and was cut (the file is still credited as covered at its current hash so the review converges; its coverage record carries atruncatedmarker, and every later round that skips the file as covered re-records the degradation with achunk_indexof-2until the file’s diff changes and it is reviewed again), a failed depth pass, or — when the synthesis pass ran —synthesis_truncated/synthesis_failed) and thechunk_index. The synthesis reasons carry a placeholderchunk_indexof-1. A chunk that was split and whose depth-3 sweep also failed contributes two entries with the samechunk_index.findings_coverage_completeis the derived “per-file finding depth was not limited” boolean: any per-file entry incoverage_degradationsmakes it false. The two synthesis reasons do not (#2702): findings coverage is per file and every file was reviewed at depth whether or not the whole-PR narrative pass saw all of it, so a truncated or failed synthesis is a narrative degradation, exposed asReviewMetadata.synthesis_degradedand described by the synthesis note, never as a partial review. The persisted run record carries the same flag assynthesis_degraded, so the sticky’s run history marks the roundℹ️ synthesis limited; it never keys the convergence stop rule or forces another round, because per-file coverage is complete and a large PR would otherwise re-review forever (#2704).- The terminal prints a
⚠ Coverage limitedbanner under the run header. - The GitHub review body (in 📊 Run stats) and the sticky comment both carry the
same warning row, and the sticky’s run history marks the round
⚠️ coverage limited. --output jsonexposesfindings_coverage_complete,coverage_degradationsandoutput_exhaustion_retriedat the payload root (andcoverage_degradationsinsidemetadata). The MCPlintro_reviewpayload carries all three on itsrunblock and hoists onlyfindings_coverage_completeto the tool-result root, next tocoverage.
Coverage limitation is a separate axis from partial. partial / stopped_reason
mean the run stopped early and planned review work was left undone (built-in chunks or
custom-agent passes, on a cost cap or an interrupt). A degraded run finished its planned
work, just not at full depth, so it is reported as its own signal with equal prominence
instead of being folded into partial. A run can be both. It is also distinct from
coverage.complete in the JSON payload, which says whether every eligible file was
covered at HEAD.
A complete run renders exactly as it always has — no banner, no warning row,
findings_coverage_complete: true — however many findings its chunks returned.
This is distinct from the hard cli_max_diff_bytes ceiling: a diff over that limit is
refused outright with DIFF_TOO_LARGE and is a hard failure, not a degraded success.
Cross-chunk contradiction guard
A chunked review shows each chunk the other files at the base commit. When a source file and its test, or a module and its importer, land in different chunks, a chunk can report in good faith that the other side “was never updated” — and a phantom P1 like that is enough to render the whole PR blocked.
A deterministic guard runs at finalize, next to the P1 evidence gate, and never asks the model anything. It fires only when both halves hold for one finding:
- its own text (title, description, cause, failure scenario) carries an explicit
unchanged claim —
is untouched,was never updated,is not in the diff,at the base revision, and the rest of the phrase set inlintro/ai/review/severity_gate.py. Incomplete-update wording such asis not updated to accept the new flagorwas not changed to handle itis deliberately not a claim: that is a real finding about a changed file; and - that text names a file the PR actually changed, other than the finding’s own file.
Matching folds case,
\to/, and-to_, and falls back to a path-suffix or basename match, somigrate-docs-content.pystill matchesscripts/migrate_docs_content.py.
Both halves must sit in the same sentence of the same field. Within that sentence,
co-occurrence is enough: the claim is not parsed for which file it predicates, so a
sentence that mixes an unchanged claim about one file with a changed file it also names
is treated as a contradiction. That is deliberate; the cost of a false positive is a
visible one-band downgrade, never a dropped finding. A bare basename such as utils.py
only counts when exactly one changed file has that name; a directory-qualified path must
match the changed path or be a /-delimited suffix of it.
On a hit the finding is tagged
cross_chunk_contradiction: unchanged_file_claim_downgraded (or
unchanged_file_claim_tagged when it was already P3, which has no lower band) and moved
down one severity band (P1 → P2, P2 → P3, P3 stays P3 and is tagged). Nothing is
dropped: the prose is kept, and a downgraded P1 can no longer drive Blocked on its
own. Questions are never touched.
The pairing is deliberate. A missed contradiction only leaves a finding at its reported severity, while a false positive would quietly demote a real defect — so the guard prefers false negatives, and an ordinary cross-file reference with no unchanged claim never fires it.
The downgrade is visible everywhere: the terminal prints the count under the findings
header, --output json carries cross_chunk_contradictions at the payload root plus a
per-finding cross_chunk_contradiction tag, and the GitHub review body (in 📊 Run
stats) and the sticky comment share one note. A run the guard did not touch renders
exactly as before on the terminal and the GitHub surfaces; the JSON keys are always
present, carrying 0 at the root and null per finding.
Prevention runs ahead of that downgrade. Every chunk prompt now carries the whole
PR’s changed-file list, not just the chunk’s own slice, with the chunk’s files marked
— **(this chunk)** and a note that the unmarked files are on disk at the base commit
and must never be read as this PR’s state. CI checks the base out shallow, so a chunk
that opened one of those files previously saw the pre-PR version and reported it as
never updated; with the full list in the prompt, the contradiction that the guard
downgrades mostly stops being written in the first place, for a few hundred extra tokens
per chunk.
The synthesis pass (summary, verdict reasoning, duplicates, cross-file findings)
Every review chunk reports findings only and sees only its own files’ diff
(lintro-ops milestone 0, decision A): the chunk answer is the findings list plus its
flagged_files re-read requests, and no chunk writes a summary, answers the checklist,
or describes files. The round’s narrative comes from one extra provider call made after
the chunk findings are merged, review.synthesis, which sees the whole changed-file
list, a digest of every reported finding (severity, file:line, title), and as much of
the whole-PR diff as its token budget allows. It is asked for four things:
summary— one headline sentence on what the change does and a 3–6 bullet walkthrough, each bullet optionally pointing at a finding byfile:line.verdict_reasoning— the single issue that decides mergeability and how it fails in production. The verdict itself is still derived in code from the open severities.duplicates— chunk findings that report the same root cause at different sites. The digest prints an id in front of every finding (F1,F2, …, its position in the merged list) and a group names those ids (keep: "F3",drop: ["F7"]); afile:lineis accepted only when exactly one finding occurs there. lintro applies groups deterministically: a group is used only when every reference resolves and all its members are the same kind (a question never merges with a finding); the highest severity survives, then the earliest reported; the dropped sites are folded into the survivor’s occurrences so no location disappears.findings— inconsistencies between files reviewed in different chunks, exactly as the cross-chunk pass always reported them (same evidence gate, sensitivity policy, cross-chunk contradiction guard, dedupe andmax_findingscap).
review:
synthesis:
enabled: true # default — disable to get findings only, no summary
max_findings: 5 # ceiling on the cross-file findings the pass may ADD (int >= 1)
How it behaves:
- It runs on every completed round that reviewed at least one chunk, single-chunk PRs included: a round without it has findings but no summary and no verdict reasoning.
- It runs only on the completed path. A round that already stopped on a cost cap, a
timeout, or an interrupt (
partial) skips the pass and spends no extra call, so a partial round carries nosynthesisblock at all. - Its input is bounded by its own budget,
ai.review_synthesis_diff_tokens(default 24,000, clamped to the context-window remainder), not the per-chunk budget: the pass is one call over the whole PR, and at the chunk budget every multi-chunk PR was over it by construction (#2702, #2704). The budget covers the whole prompt: the changed-file list and the finding digest are rendered and charged first, and the diff takes only what they leave over. A digest too large for the budget sheds its finding lines, largest chunk first, before the per-chunk file lines are touched. If the whole PR does not fit, the files that more than one chunk referenced go in first and the rest follow in path order until the budget is spent. Anything cut or dropped anywhere in the prompt setstruncated. - Its cross-file findings pass every filter a chunk finding passes: the P1 evidence
gate, the run’s sensitivity policy, the cross-chunk contradiction guard, deduplication
against the (duplicate-merged) chunk findings by the state ledger’s fingerprint, and
only then the
max_findingscap. A guarded synthesized finding keeps its"origin": "synthesis". On a resumed run they also go through the validation tail’s context-finding rejection, andfindings_addedis recomputed from what survived. - A synthesis failure is never fatal. A provider error, a timeout, a budget stop, an
interrupt that lands while the call is in flight, or an unreadable answer leaves the
chunk findings intact, applies no duplicate merges, renders the surfaces without a
summary or verdict reasoning (the TL;DR-only fallback), and marks the run’s coverage
degraded with
synthesis_failed. A budget stop during this call does not make the runpartial: every chunk was already reviewed. - On the CLI transport the envelope is constrained by a native JSON schema
(
lintro_synthesis), as the chunk call is bylintro_review.
What it adds to the surfaces:
-
The
Summaryand verdict-reasoning sections of the terminal output, the GitHub review body and the sticky comment, and thesummary,pr_summaryandverdict_reasoningkeys of--output json(null when the pass did not run or failed). -
A
synthesisphase span in the timings block (see Review phase timings below). -
One shared note on the terminal, the GitHub review body’s run-stats block, and the sticky’s
This runtable, rendered only when the pass ran:Cross-chunk synthesis added 1 cross-file finding.(plural, empty and failed forms as before). When the input was cut the note adds a sentence naming how many changed files the pass saw of how many (whole-PR token budget) and that cross-chunk duplicate merging may be incomplete: that, and a cross-file finding across the unseen files, is what a cut input can miss (#2269); the per-file findings are complete. -
A
synthesisblock at the root of--output json, present only when the pass ran. Besidesfindings_added,truncated,failed,duplicates_mergedandnarrative_missingit records the call’sinput_tokensandoutput_tokens, theoutput_limit_tokensit ran under (ai.max_tokenson the API transport,nullon the CLI transport), theinput_budget_tokensthe prompt was fitted to, theprompt_tokens_estimatedit came out at, anddiff_files_includedofdiff_files_total(#2702):{ "synthesis": { "enabled": true, "findings_added": 1, "truncated": false, "failed": false, "duplicates_merged": 2, "narrative_missing": false } }findings_addedis what survived the cap and the dedupe, not what the model returned.narrative_missingistruewhen the pass answered but wrote no usablesummaryor no usableverdict_reasoning: the findings half still counts, the round renders the TL;DR-only fallback for whichever half is missing, and the flag keeps a narrative-less answer from reading as a fully successful pass.duplicates_mergedis the number of chunk findings collapsed into another finding with the same root cause.truncatedmeans the pass saw less than its whole prompt input.faileddistinguishes a pass that could not answer from one that found nothing. The same block is on the MCPlintro_reviewpayload root. -
"origin": "synthesis"on each cross-file finding the pass contributed. -
A
synthesis_truncatedorsynthesis_failedentry incoverage_degradations(see Review coverage completeness above) when the input was cut or the pass did not complete.
The verification pass (refute before you block)
Every round makes one more provider call after the synthesis pass and before the
severity gates (#2728, lintro-ops milestone 0 step 0.11) that asks the model to
refute the findings that decide the verdict. The reviewer prompt asks “what is
wrong?”; this pass asks “is this finding real?”, with each finding’s cited hunk and the
post-change code around it in front of the model, and only counts a refutation that
cites file:line evidence from that material. It runs on the run’s own model — the
escalation-model knob belongs to the cascade issue, not this step.
review:
verify: p1+low-confidence # default; also `p1` or `off`
p1+low-confidence(default) re-checks every P1 and every finding the reviewer markedconfidence: low, P1s first, then low-confidence P2s, then P3s, at most 12 per round.p1re-checks the P1s only.offskips the call. Questions are never re-checked: they carry no severity and never move the verdict.- Three outcomes per finding. Confirmed (
unrefuted): kept, markedverified. Refuted: dropped from the round; the finding’s location, title, severity and the verifier’s evidence are recorded so a dropped finding is never silent. Weakened: the defect is real but the failure scenario did not hold at P1, so a P1 is moved to P2 with the downgrade reasonrefutation_weakened; a weakened P2 or P3 keeps its severity and counts as confirmed. A refutation or a weakening without afile:linecitation into the finding’s own file is no evidence: the finding is kept as confirmed rather than dropped or lowered on the verifier’s word alone. A citation is a whitespace-delimitedpath:line(orpath:start-end) token, or apath:linewrapped whole in double quotes when the path contains a space; a path that contains both a space and a double quote cannot be cited (a known limitation, frozen for milestone 0). - Order matters. The built-in chunk and synthesis passes no longer gate severities at parse time; the round’s findings are gated once, after this pass, so the P1 and P2 evidence gates read the verified severities. Custom-agent findings carry an author-declared severity policy and are never sent to the verifier or gated.
- A failure is never fatal. A provider error, a budget stop, an interrupt, a turn
limit or an off-schema answer keeps the selected findings unverified and records a
verification_faileddegradation — narrative, like the synthesis reasons, sofindings_coverage_completeis unaffected. With nothing selected the pass makes no call.
What you see:
Verification re-checked 3 findings: 2 confirmed, 1 refuted and dropped.under the run stats on every surface (terminal, review body, sticky fine print);… did not complete; N findings kept unverified.on a failed pass; nothing when the pass had nothing to check, so a clean round reads exactly as before.- A
verificationblock at the root of--output json, withselected,confirmed,refuted,downgraded,unanswered(selected findings the answer skipped; kept unverified),failed, therefutations(each withfile,line,severity,title,evidence) and the call’s tokens and cost, and"verified": trueon each finding the pass accepted (confirmed at its severity, or a P1 moved to P2). The run record carriesrefutedandverifiedcounts when non-zero, and the timings block averificationphase span. - With
ai.transcript_loggingon, the pass’s prompt and answer are logged like every other call, so a refutation can be read back in full.
Diff-bounded findings
Every chunk’s findings are bounded to that chunk’s diff before the P1 evidence gate
(#2711, lintro-ops milestone 0 step 0.7). The path gate already drops findings on files
outside the review scope; this gate checks the line: a finding whose file:line
lies inside a hunk of the chunk that produced it (context lines included, which is what
inline comments accept) is kept; one within ai.review_diff_gate_lines lines of a hunk
(default 3) is re-anchored to the nearest changed line of that hunk; one further away is
dropped. A finding with no line (a whole-file or deleted-file finding) is kept and
counted as unanchored, and a finding on a file the chunk’s diff does not cover is left
to the path gate. Secondary occurrences are checked one by one (dropped or re-anchored
individually); only the primary location decides whether the finding survives.
Nothing is silent: the JSON output carries findings_dropped_by_reason.outside_diff,
findings_reanchored and findings_unanchored, and the run record carries
dropped_outside_diff when non-zero, so a regression in the model’s anchoring shows in
the numbers before it shows as a thread on an untouched line or an inline-post failure.
Set ai.review_diff_gate_lines: 0 to re-anchor nothing (near findings are then dropped
too).
Repository context in the prompt
Every chunk prompt carries, besides the diff, a read-only repository context section
(#2714, lintro-ops milestone 0 step 0.8): the post-change content of each changed text
file in the chunk — source, config, workflow or doc; binaries, media and snapshot
fixtures stay out (#2731 widened this from source code only) — read from the head side
(never the working tree, which in the dogfood workflow is the base commit), plus the
chunk’s one-hop importers and sibling test files among the PR’s other changed files. A
file that fits its share of the budget is shown whole; a larger one is cut to the
enclosing function and class definitions around each hunk (ast for Python, a line
window for other languages). The section is fenced by the prompt’s boundary markers,
redacted through the same choke point as the diff, and preceded by the instruction that
it is for understanding only and that findings are reported only on lines changed in the
chunk’s diff. That instruction, the fence and the path gate (a finding on a context file
becomes a re-read flag, never a posted finding) are one security bound and are tested
verbatim.
ai.review_context_tokens (int >= 0, default 6,000) is the per-chunk budget; 0
disables the section. The tokens spent on it are reported as token_usage.context in
the JSON output and as context on the run record (when non-zero), so the before/after
measurement can see what the section cost. Both transports get the same section; on the
CLI transport it pre-loads what the agent would otherwise spend read turns on.
Review rubric and per-PR questions
Every chunk prompt reviews against a short rubric rather than a standing checklist
(#2720, lintro-ops milestone 0 step 0.9): severity defined by behaviour (P1 with a
concrete failure scenario, P2 for verified incorrect behaviour or a false documented
contract, P3 when only wording or a nit remains), ten bug classes to look in, and one
rule — a finding needs file:line evidence in the chunk’s diff, and a concern without a
shown defect is not a finding. The rubric wording is stable across rounds so run-to-run
comparisons measure the change, not the prompt.
Beside the rubric the prompt carries questions written for this change: one extra
provider call per run (not per chunk) reads the PR title, the description, the
changed-file list and the redacted whole-PR diff, fitted whole-file-by-file to
ai.review_synthesis_diff_tokens, and returns up to ten G1.… questions that every
chunk then sees under “Questions for this change (consider each; do not answer them)”.
They are guidance, never a list to answer: the output rules forbid restating a question
or a rubric item as a finding, and a confirmation that arrives as a finding is dropped
at parse time (#2430). When the diff had to be trimmed to fit, the generator is told
which files it saw and the run record carries questions_diff_trimmed. A failed call
(provider error, unparseable answer) degrades the run to the rubric alone and is
recorded once as GENERATED_QUESTIONS_FAILED; a cost-cap stop on that call ends the run
as a partial like any other.
ai.review_generated_questions (bool, default true) turns the pass off; the prompt
then says so in the questions section. --show-checklist all prints the run’s questions
under a “Questions for this change” panel, and the JSON output lists them as
generated_questions. The built-in checklist corpus is reduced to the evidence-backed
core items (logic bugs, silent failures, security, integration); the items that survive
selection render as “Additional checks” after the questions, and the corpus schema
tooling that used to accompany it is gone — the loader’s validation against the Python
enums is the only authority.
Delta rounds (what round two reads)
From round two on, a --pr review reads the delta since the last recorded round
(#2627). File-level resume already keeps the provider off every file whose whole-PR
patch hash is unchanged; a delta round narrows what the remaining chunk calls embed:
for a queued file the prompt carries git diff <prior head>..<head> for that file,
computed in the PR head worktree, rather than the whole pull-request change to it. The
rule, in one sentence: a delta round narrows what the model reads and what it may
resolve; it never narrows what it may report.
- What is read. Each chunk keeps its whole-PR hunk in
diff— that is what the diff-location gate, the cross-chunk contradiction guard and the budgets see — and gets aread_diffthe prompt embeds. The range is restricted to files the PR’s whole diff names, so a file that only a merge frommainbrought intoprior..headis never embedded; when the base branch was merged into the PR since the prior round (the recorded merge-base moved), the round reads the whole diff instead, because for a file both sides touched the delta would carry base-authored lines as the PR’s. A delta hunk that is not smaller than the whole hunk (a large change followed by a large revert) is not used. A delta round never delegatesgit diffto the agent. - What may be resolved. A prior open finding on a file the round read is resolved
only if its line lies inside a hunk the round actually read — measured on the hunks’
old side, the prior head’s coordinates a prior finding’s line is in, so an insertion
or deletion above the finding cannot shift it in or out of range; outside those lines
it is carried, not resolved (
FindingMatchResult.range_carries). The mid-run checkpoints match on the same ranges as the final round. Full rounds resolve as before. The prompt says what range the embedded text is (“the change since the previous review round … unchanged context lines may still be earlier changes of this pull request”), and the depth-3 adversarial sweep and an output-exhaustion split read the same delta text. - What is reported and counted. Findings are never filtered by delta location, and
coverage identity stays the whole-PR patch hash, so a changed file the round did not
read still forces
INCOMPLETE(ADR-0007). The question pass and the synthesis call keep the whole-PR changed-file list (ADR-0010, layer 2).
Files that carry an open finding from a prior round are queued every delta round
(FileReviewNeed.OPEN_THREAD, behind changed files) so a thread is re-litigated against
its own code; a file re-queued that way but unchanged since the prior head keeps its
whole-PR hunk. Round one, a run that is not --pr, a prior round without a recorded
head, the same head again, a rewritten branch (the prior head is no longer an ancestor —
a force-push), a merged-in base, a range git could not compute, a run without a tree,
and --full all read the whole diff; the sticky comment says which under the Findings
heading (“Round N read the delta since abc1234” / “Round N read the whole diff: the
branch was rewritten since the prior round”), and the run record carries delta_since,
delta_reason and the merge_base the next round compares against.
Every finding on the JSON surface carries a stable finding_id — the same
<fingerprint>#<ordinal> key the review state, the sticky’s Δ-table rows (a hidden
<!-- lintro-finding:… --> marker) and the inline-thread marker use. The CLI sets it
from the match against prior state after the posting policy ran, never by recomputing
ordinals from line order (two same-fingerprint findings that swapped lines keep their
keys); a run without prior state gets the key a first round would assign. The
fingerprint is a sha256 prefix over path, category and normalized title, deliberately
line-free, so a finding on head N and the same finding on head N+1 match by id.
Review convergence (deterministic re-review stop)
File-level resume already spares a long-lived PR from re-reading files it has covered at HEAD, but every round still re-reports the findings that are already open and already on the board — so a PR that has stopped moving keeps paying for rounds that say the same thing. Convergence scoring ends that remaining treadmill in code — never by asking the model.
Each round scores the findings still open after it:
score = floor + (ceiling - floor) × confidence × likelihood
| Input | Value |
|---|---|
| Severity band (floor–ceiling) | P1 6.0–10.0 · P2 3.0–6.0 · P3 0.5–3.0 |
| Confidence multiplier | high 1.0 · medium 0.6 · low 0.3 (absent ⇒ medium) |
Likelihood (evidence_style) | diff_local 1.0 · cross_file 0.8 · speculative 0.4 |
| Systemic categories | contract-drift, breaking-change ⇒ likelihood 1.0 |
The round score is the sum over the open findings. Questions are excluded, exactly
as they are excluded from the readiness verdict. Resolved findings contribute nothing,
so a round that fixed everything scores 0.00. For calibration: one high-confidence P1
read straight off the diff scores 10.00; one low-confidence P3 scores 1.25.
Every input is a field lintro already parses, so the score is a pure function of the
tracked findings — no wall clock, no randomness, no extra provider call. The score and
its trajectory are persisted per round in the review state (schema v3) and rendered
under the sticky comment’s Findings heading:
Convergence score 1.25 · trajectory 12.40 → 4.50 → 1.25
The short-circuit contract. With review.convergence.threshold set, the next round
is skipped when the last review.convergence.stable_rounds recorded scores are all
strictly below the threshold. The decision is made from persisted state before the
provider is constructed, so a converged round costs nothing:
- No provider call, no findings, no state write — the round counter, the tracked findings, and the carried coverage stay exactly as the last real round left them, and the next round that does run resumes from there.
- The sticky comment is re-rendered from the last good board with a
🔁 Converged — converged at round N (score X < threshold Y)banner. --output jsonemits a distinct envelope ({"outcome": "converged", "converged": {...}}) that carries noreadiness_verdict, nofindings, and nopartialkey.scripts/ci/classify_review_outcome.pyreports it as its own converged outcome — never as “reviewed, found nothing”. Exit 0 unlessconverged.open_p1is greater than zero, in which case the skip exits 1 and the check goes red; see the readiness-gate bullet below for the single exit contract.- A
partialor coverage-limited round can never count toward the streak: a low score from a round that never looked properly is not evidence of stability. Rounds persisted before scoring existed carry no score and are likewise not evidence. - Pending resume work blocks the skip too. If the last round left a model-flagged file to re-read, or a group/import invalidation it never served, the next round runs even when the score says quiet: a quiet score means the findings stopped moving, not that every file has been looked at. Skipping would drop that queued work rather than defer it.
- The stop rule is a
lintro review(CLI) feature. The MCPlintro_reviewtool does not readreview.convergence.thresholdand always reviews — it is a single-shot tool call with no persisted round history of its own to converge over. - A converged skip is a short-circuit of the whole command, not just the review round:
it returns before the advisory tools (
idiom-reviewand friends) run, so--fail-on-findingsis inert on that invocation and never contributes to its exit code. Nothing is inherited from the last round — a later invocation does not carry the earlier one’s advisory exit; the advisory tools simply do not run again until a round does. Runlintro review --fullto force the round and its advisory tail. This is deliberate: the skip exists to spend nothing. - A converged skip reports the open P1 findings the last real round left in force, and
treats them exactly as a reviewed round does. The CLI exits 1 locally — the same exit
a round that found them produces — while the CI check stays green on both paths:
scripts/ci/classify_review_outcome.pyreports P1 findings without reddening (see the exit-code contract inscripts/ci/run-ai-review.sh), and a skipped round is never stricter about the same findings than the round that found them. The readiness gate is informational at check level, so the count is surfaced rather than hidden behind an exit code:converged.open_p1in the JSON envelope,skipped: N open P1 findings remainin the check headline, and the same sentence on the sticky’s converged banner. review.convergence.thresholdmust be greater than zero; scores are non-negative, so a zero threshold could never be met and is rejected by config validation.lintro review --fullis the only thing that breaks the skip. The rule is evaluated from persisted state, not from what triggered the run, so asynchronizepush, a manualworkflow_dispatch, and a ChatOps re-review are all skipped just the same unless the invocation passes--full. A dispatch that must review has to pass it explicitly — this repo’s own dogfood wrapper does not.- A skip persists across later pushes. Because a skipped round writes no state, the
next push re-reads the same quiet trajectory and reaches the same decision: new
commits do not restart scoring, and the PR stays skipped until a
--fullrun records a fresh score. That is the intended behavior for a PR that has stopped moving, but it does mean enabling a threshold is not a per-push setting — plan on--fullbeing the way back to reviewing.
Disabled by default. With threshold unset — the default — behavior is identical to
a build without the feature: every round reviews.
# .lintro-config.yaml
review:
convergence:
threshold: null # float > 0; null (default) disables the stop rule
stable_rounds: 2 # consecutive sub-threshold rounds required (int ≥ 1)
Review readiness verdict
The merge-readiness verdict is derived in code from open-finding severities (never asked
of the model): any P1 → blocked; else any P2 → changes requested; else any P3 → nits
only; else ready. The review prompt calibrates the P2 vs P3 boundary that would
otherwise flip that verdict run-to-run: borderline findings must be P3, and every
finding description must name the rubric boundary it used.
Only findings that open a tracked record count (see “Confidence gate on inline
posting”): a low confidence finding routed to notes, or an open question, never moves
the verdict. A P3 opens a record even though the posting tier renders it in the sticky
instead of inline, so it still moves the verdict to nits only.
Two mechanical evidence gates run at parse time so the verdict reads gated severities,
never the model’s unaided claim, and both record their rewrite on the finding
(severity_downgraded plus a severity_downgrade_reason) so no surface presents a
gate-lowered severity as the model’s own:
- P1 gate (#1925): a P1 without a concrete
failure_scenariois moved to P2 (p1_no_failure_scenario). Counted asdowngradedon the run record. - P2 gate (#2723, lintro-ops milestone 0 step 0.10): P2 means verified incorrect
behaviour on a reachable input, or a documented contract the change makes false. A
test-gap,contract-driftorcode-smellP2 that does not claimevidence_style: diff_local— a coverage or wording concern the model traced elsewhere, inferred, or left unlabelled (the gate fails closed: an absent or unreadable label is not evidence, even though display and the convergence score normalize it todiff_local) — is moved to P3 (p2_unevidenced), so a single unevidenced test-gap claim no longer flips “nits only” to “changes requested”. Categories that name incorrect behaviour (logic bugs, silent failures, security, integration, breaking changes) are never gated this way: a cross-file trace is a legitimate way to show them. The two gates chain, so an inflated P1 in those categories ends at P3 like the honest P2 would, and a gate-lowered finding is never dropped by a sensitivity preset. Counted asdowngraded_p2on the run record (written only when non-zero). The prompt’s “a test gap is P3 unless the PR claims to fix a bug it does not test” is guidance for the model’s own P2/P3 call; the gate has no claimed-fix signal and applies the evidence rule alone, so that exception holds exactly when the model labels the missing testdiff_local.
The terminal, the per-review body and the sticky all carry one line naming each gate that fired and how many findings it moved; verdict derivation itself is unchanged and there is no knob.
Category labels are canonicalized at parse time so the gate and the sensitivity presets
compare on one spelling, and finding fingerprints are computed over that canonical
label. That is a review-state schema change (v4): a finding record persisted by a v2/v3
state may carry a hash the current parser cannot reproduce, so rather than migrate those
hashes every open record read from a pre-v4 blob is archived as rebaselined — kept for
history, never matched, never counted as open or fixed. The first round after the
upgrade reports the still-present findings as new once (their old inline threads are not
re-linked) and the findings heading names how many records were re-baselined.
A P2 “changes requested” review still exits 0. An open P1 fails the process (exit 1).
--fail-on-findings is an additional exit-1 gate when advisory tools report findings.
Exit 2 means no review was produced at all (credential, quota, or lintro-side failure).
Review phase timings
Every lintro review run is instrumented with per-phase wall-clock spans (monotonic
clock, always on, no extra provider calls). They answer which phase dominates a slow
review — provider latency, chunking, context collection, or parse/merge — so perf work
is driven by measurement rather than guesswork.
The terminal output carries a one-line summary under the header, ordered by descending duration so the dominant phase reads first:
total 4m52s — provider 4m10s (7 chunks, max parallel 5, questions 30.2s), context 22.0s, merge 8.0s, resume 1.2s, chunking 0.4s, validation 0.1s
The same line is posted to GitHub as a Timings: note under the review body’s run-stats
block and the sticky comment’s This run table (and in the run-mechanics footer of
error stickies). --output-format json carries the full breakdown in a top-level
timings block:
{
"timings": {
"total_seconds": 292.0,
"max_parallel": 5,
"phases": [
{ "name": "context_collection", "seconds": 22.0, "occurrences": 1 },
{ "name": "chunking", "seconds": 0.4, "occurrences": 1 },
{ "name": "resume_planning", "seconds": 1.2, "occurrences": 1 },
{ "name": "generated_questions", "seconds": 30.2, "occurrences": 1 },
{ "name": "provider", "seconds": 250.0, "occurrences": 1 },
{ "name": "parse_merge", "seconds": 8.0, "occurrences": 1 },
{ "name": "synthesis", "seconds": 12.4, "occurrences": 1 },
{ "name": "validation", "seconds": 0.1, "occurrences": 1 }
],
"chunks": [
{
"chunk_index": 0,
"files": 3,
"queued_seconds": 0.0,
"in_flight_seconds": 61.2,
"total_seconds": 61.2,
"failed": false,
"provider_seconds": 58.9,
"turns": 4
}
]
}
}
Reading the block:
phasesis in first-occurrence order, so it reads chronologically.provideris an envelope covering the whole chunk fan-out plus any custom-agent passes, matching thephase_timings.providerkey; the once-per-run question pass (generated_questions, #2720) is one nested span, and a phase that runs once per chunk inside it (adversarialat depth ≥ 3) folds every occurrence into one span, withoccurrencessaying how many. Those nested spans are already counted insideprovider, and chunks run concurrently, so phase sums can exceedtotal_seconds— the sum answers “how much provider work happened”,total_secondsanswers “how long did the user wait”. The summary line lists nested phases inside the provider parenthetical for the same reason, e.g.provider 4m10s (7 chunks, max parallel 5, questions 30.2s).synthesisis the round’s synthesis pass (see The synthesis pass above). It is on by default and runs after every completed round with at least one chunk, so the phase is present on a normal run; it is absent only whenreview.synthesis.enabledisfalseor no chunk completed, and then the round made no extra call.validationis the post-merge tail of the run: provider session teardown and progress callbacks, then the pass that decides what survives (context-finding rejection, coverage and resume bookkeeping, flag reconciliation). A slow session close therefore shows up here rather than only in the total.metadata.duration_secondsnow equalstotal_seconds: it includes the caller’s context collection and the validation pass, where it previously started just before the provider calls. Consumers comparing durations across versions should expect the larger figure for the same provider work.- Each chunk splits its wall clock into
queued_seconds(waiting on the concurrency semaphore) andin_flight_seconds(reviewing). A run where queued time dominates is capped by the effective concurrency ceiling, not by provider latency. That ceiling ismin(chunk count, ai.max_parallel_calls), or 1 when a cost cap serializes chunk calls (#2154); on the CLI transport it ismin(chunk count, 3)unlessai.max_parallel_callsis set explicitly, because each CLI call is a whole agent process. It is reported asmax_parallel. - Each chunk also carries
provider_seconds, the main review call’s own wall time inside the in-flight span (the difference is prompt building, parsing and any depth-3 pass), andturns, the agent turn count the transport reported for that call. The Claude CLI envelope reportsnum_turns; other transports report none, and the key is thennullrather than absent. - GitHub posting happens after the result is rendered, so it is outside the measured
window and has no phase.
metadata.phase_timingskeeps its flat three-key mapping for existing consumers.
Review design record
What the review mechanism promises — its shape (parallel file-group chunks, findings-only chunk output, one synthesis call, one verification call, transport neutrality, posting tiers), its six protocol layers, and the interim merge policy for a partial-review red check — is recorded in ADR-0010. The invariants the architecture holds are in ADR-0008, resume and artifact state in ADR-0007.
Configuration
Basic Setup
Add the ai section to .lintro-config.yaml:
ai:
enabled: true # master switch (AND-ed with the toggles below)
lint: true # AI lint summaries during chk/fmt
review: true # the `lintro review` AI diff review
provider: anthropic # "anthropic" | "cursor" | "openai"; no default
transport: api # "api" (SDK) or "cli" (local agent binary); no default
# Omit `model` and `api_key_env` to take the chosen provider's defaults
# (`lintro doctor` prints the API-key variable in effect, `lintro status` the
# model); set them only to override.
Feature Toggles
AI features are gated by a master switch plus two per-feature toggles, all off by default:
ai.enabled— master switch for all AI features.ai.lint— AI lint summarization injected afterchk/fmtruns.ai.review— thelintro reviewAI diff-review command.
A feature is active only when the master switch and its own toggle are true
(enabled AND lint, enabled AND review). This lets you, for example, enable AI diff
review without adding AI summaries to every lint run:
ai:
enabled: true
lint: false
review: true
Backward compatibility: a legacy config that sets ai.enabled: true without either
sub-toggle keeps the old behaviour — both lint and review are switched on — and
emits a deprecation warning. Set ai.lint and/or ai.review explicitly to silence it.
Full Configuration Reference
Every key below maps 1:1 to a field on AIConfig in lintro/ai/config.py, which is the
source of truth. All fields are optional; each is shown with its type, default, and
accepted range. Unknown keys under ai: are dropped with a warning rather than
rejected, so a stale key never breaks a run — but a typo never takes effect either.
The grouping below (provider, budget, safety, output, cache, advanced) is for readability only; the loader expects the flat key layout shown.
ai:
# ── Provider & transport ──────────────────────────────────────
# Master switch — all AI features are disabled when false. AND-ed with the
# per-feature toggles below. (bool, default: false)
enabled: true
# Per-feature toggles (both default to false). Effective only when
# enabled is also true.
lint: true # AI lint summaries during chk/fmt
review: true # the `lintro review` AI diff review
# Provider: "anthropic", "cursor" or "openai" ("cursor" is CLI-only).
# No default — set it explicitly whenever ai.lint or ai.review is enabled.
provider: anthropic
# How to invoke the provider: "api" (SDK) or "cli" (local agent binary).
# No default — set it explicitly whenever ai.lint or ai.review is enabled.
# "cursor" requires "cli". See "Transports".
transport: api
# Model override; omit to take the chosen provider's default. (str, default: none)
# model: claude-sonnet-4-6 # example: an anthropic model id
# Custom env var for API key (uses provider default if omitted).
# (str, default: none)
# api_key_env: MY_CUSTOM_KEY
# Custom API base URL — enables Ollama, vLLM, Azure OpenAI, or any other
# OpenAI-compatible endpoint. (str, default: none)
# api_base_url: http://localhost:11434/v1
# Provider region hint for data residency; used together with api_base_url
# for region-specific endpoints. (str, default: none)
# api_region: eu
# Ordered fallback model chain — each entry is tried in turn if the primary
# model fails. (list[str], default: [])
fallback_models: []
# Max tokens per API request. (int 1–128000, default: 4096)
max_tokens: 4096
# Max retries for transient API errors. (int 0–10, default: 2)
max_retries: 2
# Retries for HTTP 429 (rate limit) only; other transient failures keep
# max_retries. When the provider sends Retry-After, lintro waits that long
# instead of its backoff, capped at a fixed, non-configurable 300 seconds:
# a longer advertised wait is clamped to 300 seconds and still honored.
# Only a malformed or already-elapsed Retry-After falls back to the
# exponential backoff. Exhausting this budget fails with a message naming
# the rate limit and asking for a rerun.
# (int 0–20, default: 6)
rate_limit_max_retries: 6
# API request timeout in seconds. (float >= 1.0, default: 60.0)
api_timeout: 60.0
# Retry backoff parameters.
retry_base_delay: 1.0 # initial delay, seconds (float >= 0.1)
retry_max_delay: 30.0 # max delay, seconds (float >= 1.0, must be >= base)
retry_backoff_factor: 2.0 # multiplier per retry (float >= 1.0)
# ── Budget & cost caps ────────────────────────────────────────
# Max issues to attempt fixing per run. Counts API calls made, not
# suggestions returned. (int >= 1, default: 20)
max_fix_attempts: 20
# Concurrent AI provider calls (fixes and review chunk fan-out).
# Honored even when max_cost_usd is set. (int 1–20, default: 5)
max_parallel_calls: 5 # CLI transport: effective 3 unless set explicitly
# Spend ceiling per AI session, in USD; the run stops
# scheduling new calls once spent+reserved reaches the cap. null disables
# it. A cost cap serializes chunk reviews (one provider call at a time,
# #2154) so the resume queue cannot invert; the call already in flight
# when the ceiling is hit still finishes, so the final total may
# overshoot by up to one call's cost.
# (float >= 0 | null, default: null)
max_cost_usd: null
# Total review spend in USD across every AI review round of one pull
# request (#2796). Unset disables the check and the sticky line.
# Enforced like max_cost_usd: always from LINTRO_AI_REVIEW_PR_BUDGET_USD,
# from config only when spend is billed or estimated.
# (float > 0 | null, default: null)
review_pr_budget_usd: null
# Token budget for a fix prompt before context is trimmed — a soft budget,
# see "Data & Privacy". (int >= 1000, default: 12000)
max_prompt_tokens: 12000
# ── Review chunking (lintro-ops milestone 0) ──────────────────
# Per-chunk diff token budget on every transport. The semantic chunker
# splits any diff above it into small file-group chunks that are reviewed
# at depth and run in parallel. (int >= 1000, default: 7000)
review_chunk_diff_tokens: 7000
# Input budget of the cross-chunk synthesis pass (changed-file list, finding
# digest and as much of the whole-PR diff as fits), clamped to what the
# context window leaves after the prompt overhead. Separate from the chunk
# budget: the pass is one call over the whole PR. (int >= 1000, default: 24000)
review_synthesis_diff_tokens: 24000
# Generate per-PR review questions once per run (#2720): one provider call
# over the redacted whole-PR diff (fitted to review_synthesis_diff_tokens),
# the PR title and description; every chunk shares the questions beside the
# rubric. Set to false to skip the pass and review with the rubric
# alone. (bool, default: true)
review_generated_questions: true
# Diff-bounded finding gate (#2711): a finding whose line lies outside every
# hunk of the chunk that produced it is dropped and counted; within this many
# lines of a hunk it is re-anchored to the nearest changed line instead.
# (int >= 0, default: 3; 0 re-anchors nothing)
review_diff_gate_lines: 3
# Per-chunk token budget for the read-only repository context the review
# prompt carries besides the diff (post-change content of the chunk's files,
# their one-hop importers and sibling tests among the PR's changed files).
# 0 disables the section. (int >= 0, default: 6000)
review_context_tokens: 6000
# Deprecated alias for review_chunk_diff_tokens: still read (and warned
# about) when the new key is absent; removed not before 2026-10-15.
# cli_max_diff_tokens: 7000
# ── CLI-transport review limits (#1967) ───────────────────────
# Hard ceiling on the full unified-diff byte size under --transport cli;
# larger diffs fail fast with a --paths / --transport api advisory.
# (int >= 10000, default: 1500000)
cli_max_diff_bytes: 1500000
# Deprecated and ignored: the per-call findings cap was retired, so every
# chunk now reports all of its findings. Still warned about when present;
# removed not before 2026-10-15.
# cli_max_findings_per_call: 20
# Re-prompt to refine a fix that failed verification. (int 0–3, default: 1)
max_refinement_attempts: 1
# ── Safety & filtering ────────────────────────────────────────
# How to handle prompt-injection patterns detected in source files or
# diagnostics: "warn" logs and continues, "block" skips the affected file,
# "off" disables detection. (one of: off | warn | block, default: warn)
sanitize_mode: warn
# Minimum confidence for AI fix suggestions; anything below the threshold is
# discarded. (one of: low | medium | high, default: low)
min_confidence: low
# ── Review inline posting (#2572) ─────────────────────────────
# Lowest model-reported confidence a review finding needs to open an inline
# PR thread. Findings below it stay in JSON/MCP output but are routed to the
# sticky comment's collapsed "Notes and questions" block, where they never
# affect the verdict. Unlike `min_confidence` above, which discards
# low-confidence AI fix suggestions, this one reroutes review findings
# instead of dropping them. (one of: low | medium | high, default: medium)
review_inline_min_confidence: medium
# Post question-kind review entries as inline threads. Off by default:
# questions go to the "Notes and questions" block so an open question never
# blocks a merge under a zero-unresolved-threads rule. (bool, default: false)
review_post_questions_inline: false
# Restrict AI processing to matching paths / rules (glob patterns).
# Empty means "no filter". (list[str], default: [])
include_paths: []
exclude_paths: []
include_rules: []
exclude_rules: []
# ── Output & apply behaviour ──────────────────────────────────
# Set true to always run --fix in chk without the CLI flag.
# (bool, default: false)
default_fix: false
# Auto-apply fixes without interactive review (use with caution).
# (bool, default: false)
auto_apply: false
# Auto-apply deterministic style fixes (e.g. E501) in non-interactive/json
# runs. (bool, default: true)
auto_apply_safe_fixes: true
# Preview mode: show AI fix suggestions without applying them.
# (bool, default: false)
dry_run: false
# Interactive mode: validate immediately after each accepted group.
# (bool, default: false)
validate_after_group: false
# Show token count and cost estimate in output. (bool, default: true)
show_cost_estimate: true
# Extra diagnostic logging for AI operations. (bool, default: false)
verbose: false
# Stream AI responses token-by-token in interactive mode.
# (bool, default: false)
stream: false
# Post AI summaries and inline fix suggestions as PR review comments when
# running in GitHub Actions. (bool, default: false)
github_pr_comments: false
# CI exit-code control: when true, an AI error (fail_on_ai_error) or an
# unfixed/failed AI fix (fail_on_unfixed) contributes to a non-zero exit
# code. (bool, default: false)
fail_on_ai_error: false
fail_on_unfixed: false
# Lines of surrounding context sent to AI for fix generation.
# (int 1–100, default: 15)
context_lines: 15
# Max lines above/below target for line-targeted fix search.
# (int 1–50, default: 5)
fix_search_radius: 5
# ── Git checkpoints ───────────────────────────────────────────
# Refs kept under refs/lintro/checkpoints/, this run included.
# 0 keeps only the current run. (int >= 0, default: 10)
checkpoint_retention: 10
# Also checkpoint before `lintro format` mutates files.
# (bool, default: false)
checkpoint_fmt: false
# ── Suggestion cache ──────────────────────────────────────────
# Deduplicate identical fix requests across runs. (bool, default: false)
enable_cache: false
# Cache entry time-to-live, seconds. (int >= 60, default: 3600)
cache_ttl: 3600
# Max cached entries before eviction. (int >= 1, default: 1000)
cache_max_entries: 1000
# ── Provider-specific settings ────────────────────────────────
# Knobs only one provider reads live under the provider that reads them,
# never in the shared namespace above. See "Provider-specific settings".
providers:
anthropic:
# Whether to pass "--bare" to the "claude" binary. "--bare" drops the
# CLI's agentic tool surface but also disables OAuth session login, so
# it only authenticates against an API key. "auto" sends it only when a
# key is reachable (ANTHROPIC_API_KEY or an apiKeyHelper), so a
# subscription login keeps working. Override per run with
# LINTRO_CLI_BARE. (auto | always | never, default: auto)
cli_bare: auto
cursor:
# Choosing provider: cursor grants workspace trust (passes "--trust" to
# the agent CLI). Set false to restore the agent's interactive trust
# prompt. (bool, default: true)
trust_workspace: true
# OpenAI reads no vendor-only setting today; the block is recognized so
# a future one has a home.
openai: {}
# ── Advanced / trust (leave off unless you understand the risk) ──
# Let the git-native (CLI transport) review path delegate diff retrieval to
# the provider instead of embedding a redacted diff. Security risk: a
# delegated diff bypasses lintro's secret-redaction choke point — see the
# warning under "Data & Privacy". (bool, default: false)
review_allow_unredacted_git_native: false
Provider-specific settings
Settings every provider honours — provider, transport, model, max_cost_usd, the
budget and output knobs — stay at the top of the ai: block. A setting only one
vendor understands lives under that vendor instead:
ai:
provider: cursor
transport: cli
providers:
cursor:
trust_workspace: false
anthropic:
cli_bare: never
Each block is declared and validated by the provider’s own plugin
(lintro/ai/providers/<name>/config.py), so switching providers never means re-reading
which top-level keys still apply. Two kinds of key you might get wrong are treated
differently, and the split is by what is unrecognized, not by which layer you wrote
it on:
- An unknown field inside a block lintro knows fails the config load on every layer,
including
.lintro-config.yaml. The message names the full path you wrote —ai.providers.cursor.workspace_trust: Extra inputs are not permitted. A vendor’s own block is a closed set of settings, so a typo there means the setting you wanted is not applied, and failing loudly beats a silent default. - A block for a provider lintro does not know is dropped from a config file with a
warning naming it, matching how an unrecognized top-level
ai:key is treated: a staleproviders.<vendor>block must not break every run. On an override (LINTRO_AI_PROVIDERS__…or--provider-option) an unknown provider is a hard error instead, because an override typed for one invocation that silently does nothing is worse than one that stops the run.
An empty block means “this provider, all defaults”: cursor: with nothing under it and
an empty providers: section are both accepted. Blocks for providers you are not using
are kept but not applied; lintro config shows only the selected provider’s block and a
one-line count of the rest.
Nested settings resolve on exactly the same layers as the shared ones — flag > env >
project config > user config > default — and carry the same per-field provenance,
which lintro config prints in parentheses.
| Layer | Spelling |
|---|---|
| Flag | lintro review --provider-option trust_workspace=false (repeatable; applies to the provider the run resolves to) |
| Env | LINTRO_AI_PROVIDERS__CURSOR__TRUST_WORKSPACE=false — the prefix, the provider, and the field, upper-cased and joined by double underscores |
| Project | ai.providers.cursor.trust_workspace in .lintro-config.yaml |
| User | the same key in the user-level ~/.lintro-config.yaml, overridden per project |
One exception —
LINTRO_CLI_BARE. The Anthropic CLI transport readsLINTRO_CLI_BAREitself, after resolution, so it outranks every layer above forai.providers.anthropic.cli_bare— config,LINTRO_AI_PROVIDERS__ANTHROPIC__CLI_BAREand--provider-option cli_bare=…alike.lintro configreports the resolved value and its provenance, which is therefore not the value the run uses when that variable is set. It predates the nested block (#1838); no other provider setting has a second path.
The available settings today:
| Setting | Provider | Values | Default |
|---|---|---|---|
trust_workspace | cursor | true / false | true |
cli_bare | anthropic | auto / always / never | auto |
Migrating from the top-level spellings
Before this layout both settings sat on the flat ai: block. The old spellings are
still accepted for one release — they are mapped into the nested block and warned
about once per run, naming the new path — and are then removed
(#2464). When both are present, the
nested value wins.
| Old | New |
|---|---|
ai.cursor_trust_workspace | ai.providers.cursor.trust_workspace |
ai.cli_bare | ai.providers.anthropic.cli_bare |
Cursor workspace trust
Note —
ai.providers.cursor.trust_workspacegrants workspace trust by default.Choosing
provider: cursoris the consent, so lintro passes--trustto theagentCLI by default rather than stalling on its interactive trust prompt. The residual risk is that review content is untrusted and prompt-injectable: alintro review --pr Nrun embeds the diff of an arbitrary fork PR, and an injected instruction in that diff is then read by an agent operating with full workspace trust. Lintro redacts secrets and scans prompts for injection patterns, but neither is a guarantee. Settrust_workspace: falseto restore the agent’s interactive trust prompt if you want that posture; reviewing untrusted fork PRs with the Cursor provider is the case that most warrants it.
ai.providers.cursor.trust_workspace is the only default site for this flag:
CursorProvider takes it as a required argument, and the Cursor plugin always forwards
the resolved block value.
Config Defaults for CLI Flags
If you always want --fix without typing it, set the default in config:
ai:
enabled: true
transport: api
default_fix: true # equivalent to always passing --fix
CLI flags always override config: passing --fix on the CLI turns it on even if
default_fix: false, and omitting it falls back to the config value.
Providers
Every provider fact below — defaults, transports, the API-key variable, the CLI binary
and the per-model prices — is declared once, in that provider’s plugin metadata
(lintro/ai/providers/<name>/metadata.py), and read everywhere else through
lintro.ai.registry. The two tables below are snapshots of that metadata, asserted cell
by cell by tests/unit/ai/providers/test_docs_provider_table.py, so they cannot drift
from the code. There is no code generator, so updating them is two steps: change the
metadata, then run that test and paste the SNAPSHOT block it prints between the markers.
CI runs the same test, so a metadata change without the paste-back fails the build.
| Provider | Default model | API key env | Transports | CLI binary |
|---|---|---|---|---|
| Anthropic | claude-sonnet-4-6 | ANTHROPIC_API_KEY | api (default), cli | claude |
| Cursor | auto | CURSOR_API_KEY | cli (default) | agent |
| OpenAI | gpt-4o | OPENAI_API_KEY | api (default), cli | codex |
(default) marks the transport lintro documents and lintro doctor steers you to. It
is also the fallback for an omitted ai.transport: api for anthropic and openai,
cli for cursor, the only transport Cursor serves (#2449). An explicit
transport: api with provider: cursor still fails with
cursor provider only supports transport: cli. Set ai.transport explicitly anyway.
Prices are USD per million tokens, as lintro uses them for ai.max_cost_usd and the
reported $ figures. A model priced at zero is billed elsewhere (the Cursor
subscription); estimate_cost_with_floor gives those calls a non-zero estimate so a
flag or env cap can still stop the run. It does not make a committed YAML cap
enforceable on an unpriceable transport — see the Cursor section below.
| Provider | Model | Input | Output |
|---|---|---|---|
| Anthropic | claude-sonnet-4-6 | $3.00 | $15.00 |
| Anthropic | claude-sonnet-4-20250514 | $3.00 | $15.00 |
| Anthropic | claude-haiku-4-5-20251001 | $0.80 | $4.00 |
| Anthropic | claude-opus-4-20250514 | $15.00 | $75.00 |
| Cursor | auto | $0.00 | $0.00 |
| Cursor | gpt-5.3-codex-fast | $0.00 | $0.00 |
| OpenAI | gpt-4o | $2.50 | $10.00 |
| OpenAI | gpt-4o-mini | $0.15 | $0.60 |
| OpenAI | gpt-4-turbo | $10.00 | $30.00 |
| OpenAI | o1 | $15.00 | $60.00 |
| OpenAI | o1-mini | $1.10 | $4.40 |
The three are peers: lintro picks none of them for you, and ai.provider has no
default. Sections are alphabetical.
Anthropic
export ANTHROPIC_API_KEY=sk-ant-...
ai:
provider: anthropic
transport: api # or cli
# model: claude-sonnet-4-6 # provider default
See the Anthropic API docs for model options and pricing.
Cursor
export CURSOR_API_KEY=...
ai:
provider: cursor
transport: cli # cursor is CLI-only
# model: auto # provider default
The agent CLI bills against a Cursor subscription rather than per token, so its models
are priced at zero above and the run’s cost basis is unpriceable
(lintro/ai/transport.py). What that means for ai.max_cost_usd depends on where the
cap was set (cap_is_enforced in lintro/ai/review/cost_cap.py, #2154):
--max-cost-usdorLINTRO_AI_MAX_COST_USD— enforced. Operator intent for this run enforces on every cost basis, andestimate_cost_with_flooris what gives the budget a non-zero number to count against.ai.max_cost_usdin committed YAML — display-only forlintro review. Committed policy is transport-unaware and enforces only where real money is at stake (billedorestimated), sorun_planning.pybuilds the review’sCostBudgetwithmax_cost_usd=Noneand the reported$figure is a shadow estimate, not a ceiling. This is the review path only: the AI lint sessions behindlintro chk/fmtbuildCostBudget(max_cost_usd=ai_config.max_cost_usd)unconditionally (lintro/ai/orchestrator.py), so a YAML cap does stop those, priced off the same fallback estimate.
OpenAI
export OPENAI_API_KEY=sk-...
ai:
provider: openai
transport: api # or cli
# model: gpt-4o # provider default
See the OpenAI API docs for model options and pricing.
Measuring a provider choice
Provider and model choice changes what a review finds. The offline eval in
evals/review-efficacy/
runs the same corpus through every (provider, model, transport) config N times and
reports each config’s run-to-run noise floor, the cross-config finding and verdict
agreement, and precision/recall against labeled findings. It is not part of the test
suite and it spends real inference money behind a confirmation flag. The directory is
pruned from the published distributions, so running it needs a repository checkout.
Transports
Lintro reaches a provider one of two ways, selected by ai.transport:
api— the provider’s Python SDK over HTTPS. Requires theaiextra.cli— a subprocess call to a locally installed agent binary (claude,codex, Cursor’sagent).
Timeouts, cost caps, failure vocabulary, and the meaning of reported $ figures are
transport-scoped — see AI review transports for the
decision table and ai.transports.* profiles (#1923).
ai.transport has no default, so set it explicitly whenever ai.lint or
ai.review is enabled. Omitting it is always a lintro doctor incompatibility, but the
run still works: each provider plugin falls back to the transport it documents — api
for anthropic and openai, cli for cursor (#2449). That fallback exists for
backward compatibility — legacy configs that set only ai.enabled: true (which
implicitly switches lint and review on) rely on it — and is not something to depend
on in new config. Asking Cursor for transport: api explicitly is still fatal:
cursor provider only supports transport: cli.
cursor is a CLI-only provider: pair it with transport: cli. anthropic and openai
support both transports.
ai:
enabled: true
review: true
provider: anthropic
transport: api # or "cli"
Bounded CLI calls
On the cli transport every provider call is a whole agentic session, so lintro bounds
each one (#2685): the agent gets a read-only tool surface and a turn limit, declared per
provider in its metadata.
| Provider | Read-only tools | Turn limit |
|---|---|---|
anthropic | --tools Read,Grep,Glob (required) | --max-turns N (sent; backstop) |
openai | --sandbox read-only (always) | none: codex exec has no flag |
cursor | --mode ask (always) | none: agent has no flag |
The limit comes from the call’s kind: 12 turns for review-type calls (each chunk’s main
call and its schema-recovery retry, the per-PR question pass, the depth-3 pass, the
synthesis pass and custom review agents), 1 for the summary and fix calls.
ai.transports.cli.max_turns (int >= 1, default unset) overrides every kind. The two
Claude flags differ in kind. --tools is a security bound and fails closed: it is a
required contract flag sent on every call, and a binary whose --help does not
advertise it is refused before any review session starts, with the upgrade hint, because
prompt-injected repository content must never reach a writable tool; an unreadable
--help is refused the same way, and the liveness probe (lintro doctor) reports such
a binary as incompatible rather than live. --max-turns is a time bound: accepted by
Claude Code 2.x in print mode but not listed by its --help, so it is sent without the
help gate; a binary that rejects it triggers the usual unknown-option backstop (the call
is retried once without the flag and the loss is logged), and the Tier 1 contract check
reports it as unadvertised while the Tier 2 live probe proves acceptance. The turn limit
is set by lintro’s call layer for every call kind; a caller that drives a provider
directly without it gets a read-only but turn-unlimited call.
A Claude call that spends its whole turn budget without answering (envelope subtype
error_max_turns, or an error envelope whose num_turns reached the limit sent) raises
a turn-limit error that the retry loop never repeats. The chunk pass retries it once
single-shot (#2731): the same turn limit, but the generated questions are left out
of the prompt and the agent gets no tools, so it answers from the diff, the context
section and the rubric in one turn instead of re-reading the files the questions name. A
second limit records a turn_limit_reached coverage degradation, the chunk’s files are
left unreviewed for a later round, and the run is reported as a partial finding set with
the reason in run details. A run whose every chunk ends that way reviewed nothing:
it skips the synthesis narrative (there is no diff anyone read to summarise), is
reported as a stopped run with the reason
no file reviewed: every chunk hit its per-call turn limit, and exits 1 like a P1 does.
The review prompt also states what the working tree the agent can read holds, from the
context’s checkout: the change itself for a branch or uncommitted review (disk is
post-change), the base ref when that is honestly what a checkout holds (disk is
pre-change), or a request to check when it is not known; the diff is always
authoritative.
A --pr review reads the pull request’s head, never the tree the command runs in
(#2733). Before the first provider call lintro fetches refs/pull/<n>/head into a
private ref, verifies it is the head gh reported, and checks it out in a temporary
worktree under .git/lintro/pr-heads/; that worktree is the working directory of every
CLI call of the run (the question pass, each chunk, the synthesis and verification
passes) and is removed when the run ends — completed, stopped by a cost cap or a
SIGTERM, or failed. A worktree a killed process left behind is pruned by the next run.
So a corpus replay of a merged PR from a moved-on main no longer lets the agent read
today’s code and cite lines the PR never had (#2732), and the “working tree side not
determined” instruction is gone for --pr. When the command does not run inside a clone
of the repository (or the head cannot be fetched and the checkout is neither end of the
range), the run has no tree: every CLI call runs in an empty temporary repository
and without tools (the diff is always embedded, never delegated to git diff), the
prompt says so, and the run records a no_tree_for_agent narrative degradation. The
ambient checkout is never the fallback, whatever commit it is at: a matching commit says
nothing about uncommitted edits. The worktree cache lives under the git directory, where
no tracked file can plant a symlink, and is never followed through one anyway; the prune
only removes the <pr>-<oid>-<pid> entries lintro wrote whose owning process no longer
holds its lock, so concurrent reviews on one clone keep their trees.
ai:
transports:
cli:
max_turns: 12 # optional; default is per call kind (12 review, 1 summary/fix)
Both lintro check and lintro review accept --transport api|cli to override the
config for a single invocation. lintro review also accepts --provider, --model,
--review/--no-review, --max-cost-usd, and the repeatable
--provider-option NAME=VALUE for one ai.providers.<provider>.<field> setting.
Environment variables (LINTRO_AI_PROVIDER, LINTRO_AI_MODEL, LINTRO_AI_TRANSPORT,
LINTRO_AI_ENABLED, LINTRO_AI_REVIEW, LINTRO_AI_MAX_COST_USD,
LINTRO_AI_REVIEW_PR_BUDGET_USD, and LINTRO_AI_PROVIDERS__<PROVIDER>__<FIELD>) apply
to every AI surface and lose to CLI flags. There is no --enabled flag.
Invocation overrides
Resolution order for provider, model, transport, enabled, review,
max_cost_usd, and every ai.providers.<provider>.<field> setting is:
CLI flag > environment variable > .lintro-config.yaml > user ~/.lintro-config.yaml > built-in default
One resolver produces all of it, so nested provider settings carry the same per-field provenance the shared ones do — see “Provider-specific settings”.
Overlays replace the active transport profile’s cost cap
(ai.transports.api.max_cost_usd / ai.transports.cli.max_cost_usd_advisory) as well
as the legacy ai.max_cost_usd scalar.
| Variable / flag | Overrides | Notes |
|---|---|---|
LINTRO_AI_PROVIDER / lintro review --provider | ai.provider | anthropic, openai, or cursor |
LINTRO_AI_MODEL / lintro review --model | ai.model | any model id; empty env falls through |
LINTRO_AI_TRANSPORT / --transport | ai.transport | api or cli |
LINTRO_AI_ENABLED | ai.enabled | 1/0/true/false. =1 does not turn on ai.review or ai.lint. No --enabled flag. |
LINTRO_AI_REVIEW / lintro review --review/--no-review | ai.review | 1/0/true/false. The master ai.enabled switch must also be on. |
LINTRO_AI_MAX_COST_USD / lintro review --max-cost-usd | ai.max_cost_usd | USD cap. Overlay uncapped lifts. Overlay 0 is rejected (YAML 0 is $0). |
LINTRO_AI_REVIEW_PR_BUDGET_USD (env only) | ai.review_pr_budget_usd | Per-PR review budget (#2796). Same grammar as LINTRO_AI_MAX_COST_USD: positive USD or uncapped; 0 rejected. |
Do not copy a cost cap between the two surfaces.
0means different things in YAML and in an overlay:ai.max_cost_usd: 0is a real $0 cap that stops the run on the first budgeted call, whileLINTRO_AI_MAX_COST_USD=0/--max-cost-usd 0is rejected as ambiguous. Overlayuncappedlifts the ceiling outright; YAMLnull(or omitting the key) only clears the legacy scalar; a transport-profile cap (ai.transports.api.max_cost_usdorai.transports.cli.max_cost_usd_advisory) still applies.
Unset variables are absent (fall through). Invalid values fail at resolution with a
message naming the variable and the accepted values — they never silently use the config
default. Review output annotates each resolved field with its source
(provider: cursor (env), Max cost: uncapped (env)).
# Try Cursor locally without dirtying .lintro-config.yaml
LINTRO_AI_PROVIDER=cursor LINTRO_AI_TRANSPORT=cli lintro review --uncommitted
# Same thing with flags (flags win if both are set)
lintro review --uncommitted --provider cursor --model cursor-grok-4.6-high --transport cli
# Run without a cost cap (`uncapped`; overlay `0` is an error)
LINTRO_AI_MAX_COST_USD=uncapped lintro review --uncommitted
lintro review --uncommitted --max-cost-usd uncapped
# Kill switch for this environment
LINTRO_AI_ENABLED=0 lintro check .
# Enable diff review without changing the committed config
LINTRO_AI_ENABLED=1 LINTRO_AI_REVIEW=1 lintro review --uncommitted
Transport authentication
Every transport needs a credential of its own — CLI transport is not credential-free.
| Provider | Transport | Credential |
|---|---|---|
anthropic | api | ANTHROPIC_API_KEY |
anthropic | cli | claude login session, ANTHROPIC_API_KEY, or an apiKeyHelper |
openai | api | OPENAI_API_KEY |
openai | cli | codex login session (~/.codex/auth.json) or CODEX_API_KEY |
cursor | cli | agent login session or CURSOR_API_KEY (CLI-only provider) |
ai.api_key_env overrides the API-transport variable name if you keep the key somewhere
else.
Anthropic
--transport cliand the--bareflag.
claude --bareruns the CLI without its agentic tool surface, but it also disables OAuth session login — in bare mode the binary authenticates only against an API key. Lintro therefore chooses the flag per invocation (ai.providers.anthropic.cli_bare, defaultauto):
- An API key is reachable (
ANTHROPIC_API_KEYis set, or a Claude Code settings file declares anapiKeyHelper) → lintro sends--bare, and the call bills that key exactly like--transport api.- No API key is reachable → lintro omits
--bare, and the call uses yourclaudelogin session, billed to that subscription.Force either mode explicitly with
ai.providers.anthropic.cli_bare: always|neverin config, or with theLINTRO_CLI_BARE=always|neverenvironment variable (the environment wins). Codex and Cursor are unaffected — both accept a CLI login session. See #1838.
Failures are visible, never a green no-op
A missing, rejected, or depleted credential is reported, not swallowed. Lintro walks a
presence → liveness → invoke chain, and each step’s failure short-circuits to a
visible skip or failure:
- Presence — is the SDK importable, the binary on
PATH, the key variable set? - Liveness — under
api, a minimal one-token real call, because a valid key with a depleted balance authenticates and lists models but cannot serve a review. Undercli, presence plus the free--version/--helpcapability gate (no quota spent, so the result is reported as unverified quota). - Invoke — an auth or quota error at call time is classified through the same
taxonomy:
auth_failed,no_quota,rate_limited,unreachable,incompatible_cli,missing_credential.
Probe it directly:
lintro doctor # presence checks (free)
lintro doctor --ai-liveness # adds the liveness probe (one minimal API call on `api`)
lintro review distinguishes the two red states by exit code, so a wrapper can never
mistake one for the other:
| Exit | Meaning |
|---|---|
0 | A review was produced (clean, or findings below P1) |
1 | A review was produced and contains P1 findings |
2 | No review was produced — missing/dead credential, depleted balance, unreachable provider, or a lintro-side failure |
CLI compatibility floors
Each CLI transport declares a minimum supported version of the agent binary. These are known-incompatible-below floors, not known-good pins: a binary below the floor predates the flag surface lintro drives, so lintro fails with an actionable upgrade hint instead of a confusing runtime error.
| Provider | Binary | Minimum version | Upgrade |
|---|---|---|---|
anthropic | claude | 2.0.0 | npm install -g @anthropic-ai/claude-code@latest |
openai | codex | 0.20.0 | npm install -g @openai/codex@latest |
cursor | agent | 2025.1.1 | curl https://cursor.com/install -fsS | bash |
Source of truth: lintro/ai/providers/cli_contracts.py.
Above the floor, lintro tolerates flag-surface drift with a three-part guard:
- Version floor — refuse binaries known to be too old.
- Proactive gate — optional flags (
--json-schema-name,--resume,--output-schema,--trust) are checked against the binary’s--helpbefore being sent, and simply dropped when unsupported. Only their extra capability is lost. - Reactive backstop — a call that still fails with
unknown optiondrops the offending optional flag and retries.
Required flags are not gated — dropping them would degrade a review silently. Instead, CI’s contract tests assert the installed binaries still advertise them, so drift breaks CI rather than a user’s review.
Transports in CI
Both transports need their credential injected explicitly; nothing is inherited from a developer’s login.
- Fork PRs cannot read secrets. GitHub withholds repository secrets from
pull_requestruns originating in a fork, so any AI job must either skip on forks or degrade to a visible skip. Lintro’s ownai-review.ymlrequireshead.repo.full_name == github.repository, so the keyed job never runs for a fork. - Never default an unset secret. Forwarding
${{ secrets.X }}unset lets the run report a visible skip naming the missing credential; substituting a placeholder turns it into a false pass. - Trusted install. Lintro installs itself from the PR’s base ref before the step that holds the provider credential, so PR-controlled code never executes with the secret in scope. The PR is reviewed as data (the diff is fetched through the GitHub API).
- A subscription works in CI, on the
clitransport. Lintro’s ownai-review.ymlrunsai.transport: cliagainst a version-pinnedclaudeCLI (npm install -g @anthropic-ai/claude-code@<pin>) authenticated by aCLAUDE_CODE_OAUTH_TOKENsecret — no API key involved. Two things make that work: keepANTHROPIC_API_KEYout of the step env, and setLINTRO_CLI_BARE: never, because--baredisables OAuth session login (see the--barenote above). SetCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1andDISABLE_AUTOUPDATER=1to keep the binary’s egress and version predictable under an egress allowlist. - Two more subscription lanes (#2472). With provider
anthropic(the default) and theZAI_BASE_URLvariable set, the pinnedclaudebinary is pointed at a gateway (e.g. z.ai’s GLM Coding Plan endpoint) viaANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN— the CLI transport forwardsos.environto the subprocess, so no product code is involved.LINTRO_AI_PROVIDER=openairuns the version-pinnedcodexCLI on a ChatGPT-plan session: the workflow resolves the Renovate-pinned version, installs the CLI and decodes an org secret into~/.codex/auth.json(Codex has no OAuth-token env var;CODEX_API_KEYis metered API billing, not the subscription) before the review step; with the secret unset the run fails visibly at the credential gate. ai.max_cost_usdis API-path accounting. Lintro prices the tokens it billed itself, so under theclitransport the cap is advisory — the call bills the subscription (or, in bare mode with a reachable API key, that key — see the billing note above). Setting a cap serializes chunk reviews to one provider call at a time (#2154), so the session can overshoot by at most the call in flight when the ceiling is hit. Review metadata records per-phase timings (see “Review phase timings” above) so wall-clock regressions are visible in JSON / MCP output.- Two tiers of contract testing. The flag-surface tier runs
--version/--helponly — no credential, no quota — on every PR, alongside the recorded-output replay fixtures that catch CLI parser drift for free. The real-invocation tier spends quota and is manual only (workflow_dispatch), gated behind the free tier; the weekly live provider signal is the API smoke instead (#2600, seedocs/ai-review-transports.md).
Security notes: provider API keys are secrets — store them in the repository/org secret
store (or a local shell profile), never in .lintro-config.yaml, which only names the
variable through ai.api_key_env. A key with billing attached should be scoped and
rotatable; lintro doctor --ai-liveness is the cheap way to confirm a rotation took
effect.
Environment Support
AI output adapts to the environment:
| Environment | Rendering |
|---|---|
| Terminal | Rich Panels with color and structure |
| GitHub Actions | ::group:: / ::endgroup:: collapsible |
| Markdown | <details> / </details> collapsible |
| JSON | ai_summary and metadata fields |
JSON Output
When using --output-format json, AI data is included in the output.
Note: the per-tool key is
metadata. It is deliberately not namedai_metadatabecause it is not AI-specific: osv-scanner writes its suppression classifications there with AI fully disabled. The oldai_metadatakey was removed after its deprecation cycle — consumers must readmetadata.
{
"results": [
{
"tool": "ruff",
"issues": [...],
"metadata": {
"summary": {
"overview": "Code quality assessment...",
"key_patterns": ["Pattern 1", "Pattern 2"],
"priority_actions": ["Action 1", "Action 2"],
"estimated_effort": "20-30 minutes"
},
"fix_suggestions": [...]
}
}
],
"summary": {...},
"ai_summary": {
"overview": "Code quality assessment...",
"key_patterns": ["Pattern 1", "Pattern 2"],
"priority_actions": ["Action 1", "Action 2"],
"estimated_effort": "20-30 minutes"
}
}
GitHub Actions
In CI, AI summary appears as a collapsible group in the workflow log. No special
configuration needed — Lintro auto-detects GITHUB_ACTIONS=true.
Cost Control
Estimated Costs
AI features use minimal API calls:
| Feature | API Calls | Typical Cost |
|---|---|---|
| AI Summary | 1 per run | ~$0.01 |
| Fix suggestions | 1 per issue (up to limit) | ~$0.01 each |
| Post-fix summary | 1 after fix review | ~$0.01 |
Reducing Costs
- Limits —
max_fix_attempts(default 20) caps API calls - Opt-in flags —
--fixis opt-in; only the summary runs by default (1 call) - Cost display —
show_cost_estimate: trueshows token usage and estimated cost after each AI operation
Disabling AI
ai:
enabled: false # disables all AI features
Or simply don’t install the extra:
uv pip install lintro # no AI support
Retry and Error Handling
AI API calls use exponential backoff retry:
- Max retries: 2 (3 total attempts)
- Backoff: 1s, 2s (capped at 30s)
- Retried errors: rate limits, transient provider errors
- Not retried: authentication errors (fail immediately)
By default (ai.fail_on_ai_error: false) AI failures never break the main linting flow.
If the provider is unavailable, you get your normal linting results with a one-line
notice naming the exception class and the first line of the provider’s message (secrets
redacted); the full traceback stays at debug level. With ai.fail_on_ai_error: true the
provider error is re-raised instead, so the run exits non-zero:
AI: enhancement unavailable (AIProviderError: claude exited with status 1)
Non-JSON review responses
lintro review asks the model for a JSON object. When an answer comes back as prose
instead, the findings it contains are recovered rather than discarded:
- The response is parsed, including JSON embedded in surrounding prose.
- If that fails, lintro makes exactly one retry asking the model to re-emit its
answer in the required schema. The retry is charged against the same per-call
ai.api_timeoutbudget as the original call (capped at half of it), and is skipped entirely when too little of that budget remains. - If the retry also fails, the prose is reported as a single low-severity “unstructured review output” finding carrying the complete answer, and the chunk completes instead of aborting.
Every response that fails to parse — at the CLI-envelope layer or the review layer — is
written in full to .lintro-cache/ai/raw-responses/, so nothing the model produced is
lost to truncation.
Pre-Execution Summary
When AI is enabled, the pre-execution summary table includes AI configuration:
┌───────────────┬──────────────────────────────────┐
│ Setting │ Value │
├───────────────┼──────────────────────────────────┤
│ AI │ enabled │
│ │ provider: anthropic │
│ │ model: claude-sonnet-4-... │
│ │ parallel: 5 workers │
│ │ safe-auto-apply: on │
│ │ verify-fixes: off │
└───────────────┴──────────────────────────────────┘
This shows provider status, SDK availability, API key presence, and operational settings at a glance.
Docker with AI
Two images are published. Which one you need depends on the transport.
| Image | Contains | Use when |
|---|---|---|
ghcr.io/lgtm-hq/py-lintro | The lint toolchain. Lean; no provider SDKs, no agent CLIs. | Linting only, or AI off. |
ghcr.io/lgtm-hq/py-lintro-ai | Everything above plus the ai extra and the baked claude, codex and Cursor agent CLIs. | --transport api or --transport cli in a container. |
Both carry the same tag scheme (latest, 0.94, 0.94.2, sha-<commit>) and are
cosign-signed. The ai variant is a strict superset built FROM the base image’s
full stage, so nothing is lost by using it — it is simply larger, which is why the
lint image stays free of it.
To use AI features, pass your API key as an environment variable. The provider
defaults to ai.provider in the mounted .lintro-config.yaml, and can be overridden
per run with LINTRO_AI_PROVIDER or lintro review --provider without editing that
file. --transport (and LINTRO_AI_TRANSPORT) override the invocation path. The
examples pass the key by name (-e VAR, no =value), so the secret is inherited
from the shell’s environment instead of appearing in the container’s argument list.
# API transport, with `ai: {provider: anthropic}` in the mounted config
docker run --rm \
-e ANTHROPIC_API_KEY \
-v $(pwd):/code \
ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport api
# API transport, with `ai: {provider: openai}` in the mounted config
docker run --rm \
-e OPENAI_API_KEY \
-v $(pwd):/code \
ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport api
# CLI transport — the agent binaries are already on PATH in this image.
# The credential is still required (see "Transport authentication" above).
docker run --rm \
-e ANTHROPIC_API_KEY \
-v $(pwd):/code \
ghcr.io/lgtm-hq/py-lintro-ai:latest check . --transport cli
The agent CLIs live in /opt/ai-tools, installed from the digest-pinned
ghcr.io/lgtm-hq/lintro-ai-tools base image and refreshed weekly. Only
/opt/ai-tools/bin goes on PATH, so the lint toolchain’s own runtimes are untouched.
Because the bundled CLIs are rebuilt on a weekly cadence while the vendors release far
more often, the capability guard above is what absorbs the lag.
Building locally, the base image adds the AI extras (SDKs, not CLIs) with
WITH_AI=true, and the full ai variant is its own build target:
docker build --build-arg WITH_AI=true -t lintro-ai . # API transport only
docker build --target ai -t lintro-ai-full . # + baked agent CLIs
Outside Docker, the agent CLIs are bring-your-own: install them yourself (pip, Homebrew, npm, the vendor installer) and keep them at or above the compatibility floors.
Troubleshooting
AI: enhancement unavailable
SDK not installed:
uv pip install 'lintro[ai]'
API key missing:
# Anthropic
export ANTHROPIC_API_KEY=sk-ant-...
# OpenAI
export OPENAI_API_KEY=sk-...
# Or use a custom env var
# .lintro-config.yaml
# ai:
# api_key_env: MY_CUSTOM_API_KEY
Unknown provider:
Only anthropic, openai and cursor are supported (cursor is CLI-transport only).
Check your ai.provider value.
Rate Limits
If you hit rate limits, the retry logic handles transient 429 errors automatically. For persistent rate limiting:
- Reduce
max_parallel_calls(e.g., from 5 to 2) - Reduce
max_fix_attempts
High Costs
- Check
show_cost_estimate: trueis set to monitor usage - Lower
max_fix_attempts(default 20) if fix generation is too expensive - Avoid
default_fix: truein config unless you want fixes every run - Use
--fixonly when needed
Data & Privacy
What is sent to the AI provider
- Summary mode (
lintro check): An issue digest containing error codes, counts, issue messages, and workspace-relative file paths. No source code is sent. - Fix mode (
--fixorlintro format): The source of the file carrying the issue, plus the issue message and error code. How much of that file is sent depends on its size — often all of it. See below. - Review mode (
lintro review): The unified diff under review — changed lines with their surrounding hunk context — the workspace-relative paths of the changed files, and, when lint results are available, a digest of them. The diff passes through lintro’s secret-redaction step first.
Warning —
ai.review_allow_unredacted_git_nativesends unredacted diffs.With this option enabled, the CLI transport asks the provider to run
git diffitself instead of embedding lintro’s redacted diff. The result never crosses lintro’s redaction choke point, so any secret, token or other sensitive content present in the diff reaches the provider’s backend verbatim. It defaults tofalse. Enable it only in a controlled, trusted environment, on diffs you have confirmed carry no secrets, and only when delegated retrieval is needed for a very large diff.The delegated path also needs an agent that can run a command. Under the bounded read-only tool surface (see Bounded CLI calls) Claude’s
--tools Read,Grep,Globhas no shell, so for theanthropicprovider the opt-in is ignored: an oversized chunk takes the embedded (redacted) path with its usual split and truncation handling, a warning is logged, and the run record carriesdelegated_diff_embedded: true. Codex and Cursor can still rungit diffin their read-only modes.
How much source code fix mode sends
Fix mode is the one path that can send a whole source file. The amount is
size-dependent, governed by FULL_FILE_THRESHOLD (500 lines) in
lintro/ai/fix_context.py:
- Files at or under 500 lines — the entire file is attempted first, so the model
can reason about the whole file when generating a fix. It is sent in full only if the
resulting prompt fits within
max_prompt_tokens; otherwise lintro falls back to the window below. - Files over 500 lines — only a window around the issue line is sent
(
context_lines, default 15 lines either side). The same windowing catches a small file whose full contents would blow the prompt token budget. - Batch path — when several fixable issues share one file, the batch prompt embeds
the sanitized full file regardless of line count, falling back to per-issue
prompts only when the estimated batch prompt exceeds
max_prompt_tokens. - Token budget —
max_prompt_tokens(default 12000) is a soft budget. The single-issue path halves the window down to a 3-line floor and then sends the prompt anyway if it is still over; refinement prompts build a fixed window with no budget check at all. Very wide lines or refinement retries can therefore exceed the cap.
Secret redaction and prompt-injection scanning are applied to every prompt lintro
assembles, regardless of context size. The one documented exception is
ai.review_allow_unredacted_git_native, which delegates diff retrieval to the provider
and so bypasses redaction entirely (see the warning above). If sending whole files is
unacceptable in your environment, keep fix mode off — max_prompt_tokens alone is not a
guarantee.
What is NOT sent
- Absolute paths — all paths are made relative to the workspace root before sending
- Other project files — in summary and fix modes, only files with reported issues are read; in review mode, only files that the diff under review touches
- Detected secrets — recognized secret patterns are redacted from the file content, the issue message, and any context window before the prompt leaves lintro
Workspace boundary enforcement
AI fix suggestions are validated against the workspace root. Fixes targeting files outside the workspace are rejected and never applied.
Local transcript logging (opt-in)
Raw provider request/response traffic can be written as NDJSON under
.lintro-cache/ai/transcripts/ for debugging. This is off by default. Enable with
ai.transcript_logging: true or LINTRO_AI_TRANSCRIPT=1.
- Transcripts stay on the local machine only (not uploaded by lintro)
- Payloads are secret-redacted before write; API keys and auth headers are never logged
- Older transcript files are pruned (default: keep last 10 runs via
ai.transcript_retention) - Files are named
<UTC timestamp>-<label>.ndjson. The label isreviewfor a review (CLI or MCP) andaifor everything else — lintro states it from the call site rather than guessing it from the command line, so the label never drifts behind the CLI surface. Every event payload already carries the provider, transport and direction, so the label is cosmetic.
Important notes
- AI suggestions can hallucinate incorrect fixes — always review before accepting
- See your provider’s privacy policy for data retention: Anthropic, OpenAI