Design notes

The guiding rule: do everything you can deterministically, and send the model the smallest evidence that explains the failure.

1. Pipeline

run / read  ->  parse  ->  build context  ->  redact  ->  (cache) -> Haiku -> render [-> apply]

A command that exits 0 stops at step one. In pipe mode the exit code is unknown, so the parsers decide: if nothing error-like is found, ee says so and exits 0.

2. Parsers

Each parser returns an ErrorInfo(language, kind, message, excerpt, frames) or None. They run in a fixed order (pytest, Python, Rust, Go, tsc, GCC, Java, Node) and the first match wins, so the more specific formats are tried before looser ones. ANSI codes are stripped first. frames are ordered most relevant first, which is what drives the context budget.

Python tracebacks print the innermost frame last, so frames are reversed. Chained exceptions are handled by walking back from the last Traceback header while the preceding non-blank line is one of the two chaining sentences, so the excerpt covers the whole chain:

Traceback ... JSONDecodeError                      <- start of chain
The above exception was the direct cause ...
Traceback ... RuntimeError
During handling of the above exception ...
Traceback ... KeyError: 'fallback'                 <- message

Java prints Caused by: blocks from outermost to root cause; the root cause becomes the message and its frames come first. JDK frames (java., jdk., sun., kotlin.) are dropped. Node internals (node:...) are dropped.

pytest failures use the first block of the FAILURES/ERRORS section: the E lines (assertion rewrite, dedented) are the message, and the path.py:line: markers are the frames.

The generic fallback finds the last line containing an error-ish word, takes 5 lines before and 10 after, and harvests file:line[:col] locations as frames. With a non-zero exit code and no error-ish words it uses the last 20 lines.

3. Context builder

For each frame, in order:

  1. Resolve the path. Absolute paths and paths relative to the root are tried first; Java/Kotlin frames only carry a file name, so the repo is searched by basename and the package path from the qualified method name breaks ties (com.acme.Config.validate prefers .../com/acme/Config.java).
  2. Reject anything that resolves outside --root or matches .eeignore (reported to the user as "not sending ..."). Vendor directories (site-packages, node_modules, .venv, ...) are skipped unless the frame is the top one.
  3. Compute [line-12, line+12] and, if the enclosing function is at most 80 lines, widen to cover it. Python uses ast (decorators included); other languages walk upward to the nearest line with smaller indentation that looks like a declaration (def/function/func/fn/class keyword, or a line ending in { with a (), skipping control flow, then down to where the indentation returns, including a closing brace.
  4. Collect ranges per file and merge overlaps and adjacent ranges, so five frames in one function become one snippet.

Rendering walks files in first-seen order (the top frame's file first). The budget is budget_tokens * 4 characters; a snippet that does not fit is cut at a line boundary, and the build stops once less than 200 characters remain.

Worked example: for examples/pyapp/report.py the frames are inventory.py:12, report.py:8, report.py:12. Frame 1 gives lines 9-13 (function total_cost) widened to the window 1-13, frames 2 and 3 give window 1-12 (and the main span) which merge into one range. Two snippets, about 500 tokens in total.

Environment facts: OS, the language's --version and, for Python and JS imports found in the snippets, the version specifiers declared in requirements*.txt, pyproject.toml or package.json (names normalised, so Flask_Cors matches flask-cors).

4. Redaction

Applied to the final prompt, so it covers the error output, source and environment alike. Two layers: known token shapes (Anthropic/OpenAI sk-, AWS AKIA, GitHub, Slack, JWT, bearer tokens, PEM private keys, user:pass@ in URLs) and key/value assignments whose key contains password, secret, token, api_key, auth, ... where only the value is replaced, so the code stays readable: API_KEY = "[REDACTED]". It is regex based and best-effort; use .eeignore for files that must never be read.

5. Model call and cache

client.messages.parse with the Explanation pydantic model as output_format, output_config={"effort": "low"}, max_tokens=2048, and no sampling or thinking parameters. The cache key is sha256(json([model, system prompt, user prompt])), so changing the model, the instructions or any part of the evidence is a miss, while re-running the same failure is free. Refusals and truncated responses are reported rather than cached. Errors map to short messages: authentication (setup instructions), rate limit (with retry-after), bad request, connection and 5xx.

6. Applying patches

The patch must name files inside the root; each path is resolved and rejected if it escapes. git apply --recount --check runs first (--recount tolerates the miscounted hunk headers models tend to produce). Only after a successful check and a confirmation (or --yes) is the patch applied. Note that git treats a hunk with fewer trailing than leading context lines as end-of-file anchored; the prompt asks for symmetrical context.