Self-healing builds

When a target fails, Zuke can hand the failure to an AI fixer that diagnoses it, proposes (or applies) a fix, and lets the build re-run the real command to verify. @zuke/ai's aiFixer is built on a small core primitive — recoverWith — so the loop is part of the typed build graph, not a bolt-on script.

Unlike a standalone tool, the fixer has the one thing that makes a good fix possible: the exact command that failed, its captured stderr, and the diff around it. The real build command is the verifier — a fix only "heals" when the command actually goes green.

The recoverWith primitive

A Remediation is the failure-time sibling of a Validation: an object with a remediate(ctx) method that runs only after the target body fails. It receives the failure and returns whether the body should be re-run.

import { Build, parameter, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
import { aiFixer } from "jsr:@zuke/ai";

class CI extends Build {
  key = parameter("OpenAI API key").secret().required();

  test = target()
    .executes(() => DenoTasks.test((s) => s.allowAll()))
    // On failure: diagnose from the error output + diff, then propose or heal.
    .recoverWith(aiFixer((f) => f.provider("openai").apiKey(this.key)))
    .recoverAttempts(2); // up to two fix-then-rerun cycles (default 1)
}

await run(CI);
  • .recoverWith(...r) attaches one or more remediations; the first to ask for a retry re-runs the body.
  • .recoverAttempts(n) bounds how many fix-then-rerun cycles are tried (default 1).
  • A remediation that throws is treated as "could not heal" — it never masks the original build failure.

Any object with a remediate method qualifies, so recoverWith isn't AI-specific — a deterministic "run deno fmt, then retry" remediation is just as valid.

Per-target or global

Attach a fixer to one target with .recoverWith(...), or override recoverWith() on the build to apply it to every target at once. Both styles compose — a target's own remediations run before the build-level ones:

class CI extends Build {
  key = parameter("OpenAI API key").secret();

  // Applies to every target below — no per-target wiring needed.
  override recoverWith() {
    return [aiFixer((f) => f.provider("openai").apiKey(this.key))];
  }

  lint = target().executes(() => DenoTasks.lint());
  test = target().executes(() => DenoTasks.test((s) => s.allowAll()));
}

aiFixer — safe by default

With only a provider and key, aiFixer is diagnose-only: it sends the failed command, its output, the diff, and your project conventions (CLAUDE.md / AGENTS.md) to the model, parses a structured fix, and reports — without touching any files. The diagnosis lands in the GitHub Actions job summary and on the pull request.

test = target()
  .executes(() => DenoTasks.test((s) => s.allowAll()))
  // Provider + key only → diagnose-only: reports, writes no files.
  .recoverWith(aiFixer((f) => f.provider("openai").apiKey(this.key)));

.provider("claude" | "openai" | "gemini") and .apiKey(...) are the only required calls — the same providers and structured-output enforcement as AI review. The key is read from a parameter().secret(), so Zuke masks it in CI.

Copilot-style inline suggestions

When the fix is only proposed (diagnose-only — the default), the fixer posts each problem on GitHub as an inline review comment with a committable suggestion block, anchored to the exact file:line, deduplicated across re-runs, and skipped gracefully if a line isn't in the diff. The structured fix carries per-problem locations — file, line, the verbatim offending code, and the replacement — so the comment shows real code, not prose.

When the fix is applied (.autoApply()), a committable suggestion would be contradictory — the change is already made — so the fixer posts a single overview comment showing what it fixed (with the code diff) instead. Off GitHub, or when the model reports no specific locations, it falls back to the overview comment too. Use .noSuggest() to force the overview comment even in diagnose mode.

Applying & committing fixes

Escalate from diagnosis to action, behind explicit guards:

aiFixer((f) =>
  f.provider("openai").apiKey(this.key)
    .autoApply()                          // write the fix to the working tree
    .allowPaths("packages/**", "src/**")  // allowlist; lockfiles/.git/CI excluded
    .maxEdits(5)                          // blast-radius cap
    .allowCI()                            // default is local-only; opt in for CI
    .commitFixes()                        // stage, commit & push to the PR branch
);
SettingEffect
.autoApply()Write the proposed fix to the working tree and re-run the target. Off by default.
.allowPaths(...globs)Restrict applied edits to matching paths (lockfiles, .git, CI workflows, and key material are always excluded).
.maxEdits(n)Cap how many files one fix may touch.
.allowCI()Permit auto-apply / commit on CI (local-only by default).
.commitFixes()Stage, commit, and push the fix so a healed PR carries it. Implies .autoApply().
.noPush() / .commitMessage(...)Tune the commit.

A fix is never auto-committed unless you opt in, and the post-apply re-run is always the gate: a bad edit fails the build instead of landing silently.

Delegating to a coding agent — agentFixer

aiFixer makes one structured API call and applies the edits itself. For open-ended failures, agentFixer instead hands the failure to a coding agent you inject — Claude Code, Codex, or the Gemini CLI — which reads and edits files autonomously; the executor then re-runs the target to verify. There's one generic fixer, not one per agent: you pick the agent at the call site.

import { agentFixer } from "jsr:@zuke/ai";
import { ClaudeTasks } from "jsr:@zuke/claude";

test = target()
  .executes(() => DenoTasks.test((s) => s.allowAll()))
  .recoverWith(
    agentFixer((ctx) =>
      // ctx.prompt is assembled from the failure; ctx also carries the raw
      // target / command / output if you'd rather build your own.
      ClaudeTasks.run((s) => s.prompt(ctx.prompt).permissionMode("acceptEdits"))
    ),
  );

Any runner that takes the context and returns works — CodexTasks.exec, GeminiTasks.run, or a custom function. Because the agent edits files directly, agentFixer is gated to local runs by default (.allowCI() to opt in). It reuses the same .comment() / .commentToken() / .criteria() / .conventions() knobs as aiFixer, and mirrors the same propose-vs-apply rule:

  • .suggest() (propose) — render the agent's git diff as committable inline suggestions on the PR and leave the build failed for a human to apply. Suggestions only ever appear in this not-auto-fixing mode.
  • .commitFixes() (apply) — stage all of the agent's changes, commit, and push (no commit if it changed nothing), then re-run the target to verify, and post an overview comment of what it did. Takes precedence over .suggest().

Diff context without a CI step

For good diagnoses the fixer wants the PR diff. .diff((d) => d.fetchBase()) makes the fixer fetch the base branch itself — auto-detected from the CI environment (GitHub's GITHUB_BASE_REF) — so your workflow needs no manual git fetch step. Pass a branch (.fetchBase("main")) to be explicit; if the fetch can't run, the fixer falls back to the working-tree diff.

aiFixer((f) =>
  f.provider("openai").apiKey(this.key)
    // Fetch the base branch itself — auto-detected from GITHUB_BASE_REF —
    // so the workflow needs no manual `git fetch` step.
    .diff((d) => d.fetchBase())
);

Cost controls & learning

Three opt-in primitives bound what the AI costs and teach it what to ignore. They are shared objects you construct once and hand to any number of reviewers and fixers.

Token / cost budget

budget(...) caps spend across every reviewer and fixer it is attached to. Each call folds its reported token usage into the running total; once a cap is reached, the next AI step is skipped (not failed) with a note, rather than running up the bill.

import { budget } from "jsr:@zuke/ai";

class CI extends Build {
  key = parameter("OpenAI API key").secret();
  ai = budget((b) => b.maxTokens(200_000)); // exact token cap

  lint = target().executes(() => DenoTasks.lint())
    .recoverWith(aiFixer((f) => f.provider("openai").apiKey(this.key).budget(this.ai)));
  test = target().executes(() => DenoTasks.test((s) => s.allowAll()))
    .recoverWith(aiFixer((f) => f.provider("openai").apiKey(this.key).budget(this.ai)));
}

Token counts come straight from the provider's reported usage, so .maxTokens(n) is an exact cap that never goes stale. No prices ship — provider pricing changes too often for a baked-in table to stay correct. A USD cap is opt-in: give the budget your own current rates and it estimates cost from them.

budget((b) =>
  b.maxTokens(200_000)
    .prices({ "gpt-5.4-mini": { input: 0.4, output: 1.6 } }) // USD per 1M tokens
    .maxCost(1.0) // only enforced for models you've priced above
);

A model with no supplied price still counts toward the token cap — only its cost is left out, and a cost cap is enforced only once a priced call is recorded.

Fix / response cache

aiCache(...) reuses a prior model response for an identical call (same provider, model, and prompt) instead of paying for another one — handy when the same failure recurs across CI re-runs. A cache hit costs nothing and does not draw down the budget.

import { aiCache } from "jsr:@zuke/ai";

const cache = aiCache((c) => c.dir(".zuke/ai-cache").ttl(86_400)); // 1-day TTL
aiFixer((f) => f.provider("openai").apiKey(this.key).cache(cache));

The TTL defaults to 7 days (0 never expires) and the store is best-effort — see Caching → AI response cache for the full behaviour, the .disable() / .store() knobs, and custom stores.

Learned false-positive suppression (review)

suppressions(...) hides reviewer findings whose stable ID you've dismissed. Every finding is fingerprinted and its ID surfaced in the report and PR comment, so silencing a recurring false positive is a copy-paste of that ID into the suppress list (.zuke/ai-suppress.json, a JSON array of IDs):

import { suppressions } from "jsr:@zuke/ai";

securityReviewer((r) =>
  r.provider("openai").apiKey(this.key)
    .suppress(suppressions((s) => s.file(".zuke/ai-suppress.json")))
);

Suppression is review-only — a fixer applies a whole fix, not individual findings, so .suppress() lives on reviewers such as securityReviewer, not on aiFixer. A suppressed finding still appears in the report and PR comment under a Suppressed (not gating) section — with its severity, location, and ID — so suppression mutes the build break without burying the finding.

Other knobs

  • .model(...), .effort(...) — pick the model and thinking depth.
  • .criteria(...) / .conventions(...) — add or override the project notes sent to the model.
  • .comment() / .noComment() — toggle PR posting (the job summary is always written).
  • .maxDiffTokens(n), .retry({ ... }), .quiet() — budget, transient-failure retry, and output control.

There's no .skipIfKeyMissing() on the fixer — when the API key is missing (a fork PR with no secret, say), aiFixer skips automatically and unconditionally, announcing the skip rather than failing the build.

Dogfooding

Zuke's own lint target uses recoverWith(aiFixer(...)). On a lint failure in CI it posts a committable suggestion to the pull request and a clean job-summary diagnosis, reusing the same OpenAI key as the AI review. On fork PRs the key is absent, so the fixer simply skips and the build fails normally.

See the self-healing example for a complete build file, AI code review for the gate-the-build side of @zuke/ai, or Validations for the core seam that remediations mirror.