§00 / Manifest live at HEAD

Offload the expensive half of GitHub Actions onto Cloudflare.

FlareDispatch moves agentic code review, Playwright e2e, acceptance suites, sharded matrices, and security scans off GitHub Actions and onto Cloudflare — billed per vCPU-second on your own account. Trigger from a GitHub Actions step or a cron schedule — or skip Actions entirely: install the GitHub App and runs fire themselves on every PR, with zero GitHub Actions minutes and no workflow files to maintain.

§01 / Why it pays off

Heavy CI, at the price of raw serverless compute.

01 / Cost

Pay per vCPU-second

Heavy compute is billed at Cloudflare Container rates — per vCPU-second, scale-to-zero between runs — instead of GitHub Actions per-minute. The jobs that dominate your CI bill move to the cheapest place to run them.

02 / Headroom

No platform ceilings

No 6-hour job limit. No 10 GB cache cap. Artifact retention you set, not a fixed 90 days. The limits that push teams onto self-hosted runners simply aren't there.

03 / Fit

Install the App — or keep Actions

Pure webhook mode is the leanest path: install the GitHub App and runs fire themselves on every PR — no .github/workflows file, zero GitHub Actions minutes, one secret to set. Or keep GitHub Actions as the trigger and PR gate when a run must interleave with other jobs. Either way it's one wrangler deploy into the Cloudflare account you already pay for, and results land as a GitHub Check Run — no new dashboard, no runner fleet.

§02 / The bottleneck

Most of a code-review run is the model thinking, not your CPU working.

An agentic review is mostly LLM I/O — the run sits blocked on the model while the vCPU is idle. GitHub Actions bills that wait by the wall-clock minute. FlareDispatch runs on Cloudflare Containers, billed per vCPU-second, so the idle wait scales toward zero and you pay for the active slivers — here, roughly a fifth of the run.

BILLED TIME · ONE CODE-REVIEW RUN read diff review ① review ② synthesize GitHub Actions 10 min Flare Dispatch free free free free ~2 min wall-clock time of one run → vCPU-active — billed by both LLM I/O wait — GitHub Actions only
A code-review run is roughly 20% vCPU-active and 80% LLM I/O wait. GitHub Actions bills the entire wall-clock run; FlareDispatch bills only the vCPU-active fraction, because the model-wait runs in a CPU-time-billed Workflow step.
§03 / Positioning

Every option trades away one of three things. FlareDispatch keeps all three.

Hosted runners are easy but billed per minute with hard ceilings; self-hosted and hybrid runners drop the ceilings but hand you a fleet to operate. FlareDispatch runs on serverless Cloudflare primitives — in the account you already own.

Heavy-CI option Serverless, scale-to-zero compute No 6 h / 10 GB ceilings No runner fleet to operate
GHA hosted runners
Self-hosted runners
Buildkite Agent (hybrid)
FlareDispatch
§04 / The code

Every recipe — toggle the workflow against its typed run.

Configurable single- or multi-agent code review on every PR — plus a nightly sweep. Fires on every pr push. Webhook Action Schedule Full recipe →
// Recipe: AI code review on every PR
//
// A FlareDispatch port of Cloudflare's multi-agent code reviewer —
// https://blog.cloudflare.com/ai-code-review/ — see ./README.md for how the
// blog's design maps onto this run.
//
// Drop this file into your repo's `runs/`; it is identical to the deployed
// `runs/pr-review.ts`. The review engine (`@flare-dispatch/review-agent`) runs
// in the Worker — no `review-agent` CLI in the container image.
//
// --- v3: the review runs IN THE WORKER, not a container CLI ------------------
//
// Earlier versions shelled out to a `review-agent` CLI baked into the run's
// container image. That CLI does not exist in the deployed image (every exec
// exited 127), so reviews silently failed. v3 moves the review into the run
// body via `@flare-dispatch/review-agent`, which calls a model through the
// `modelGateway` capability — backed by the Cloudflare Workers AI binding
// (`env.AI`) via an AI Gateway. The binding is the auth (Workers AI is
// account-billed), so NO model API key is configured. The ONE container image
// (infra/Dockerfile.sandbox: Node + git + curl) is used only for `git`
// (checkout + diff); every model call happens in the Worker, against a
// CONFIGURABLE backend resolved from CONFIG_KV.
//
// --- CONFIG the operator sets (out of band) ---------------------------------
//
//   CONFIG_KV  pr-review.agents         "single" (one generalist reviewer) | "multi" (tier-scaled per-domain personas)  (default "multi")
//   CONFIG_KV  pr-review.backend        "workers-ai" | "anthropic" | "bedrock"  (default workers-ai). A MODEL ROUTE, not an agentic tool — `opencode`/`reasonix` are reserved for the agent tier (specs/09-agentic-review.md), not this one.
//   CONFIG_KV  pr-review.prompt          (optional) REPLACE the reviewer system prompt
//   CONFIG_KV  pr-review.guidelines      (optional) ADDITIVE house rules appended to the reviewer prompt (suppression rubric / conventions / severity calibration)
//   CONFIG_KV  pr-review.workers-ai.model  model id — a bare Workers AI catalog id (e.g. @cf/meta/llama-3.3-70b-instruct-fp8-fast, account-billed, no key) OR a `deepseek/`-prefixed hosted reasoner (e.g. deepseek/deepseek-reasoner, BYOK via AI Gateway — the real model)
//   CONFIG_KV  pr-review.workers-ai.mode   "tools" | "json"  (default "tools"; pin "json" for reasoning models — DeepSeek-class models ignore tool-calls)
//   CONFIG_KV  pr-review.<backend>.maxDiffChars  (optional) override the per-backend diff cap — a positive int clamped to [1_000, 1_000_000]. Defaults: workers-ai 60_000, anthropic/bedrock 240_000. Raise it for a big-context Workers AI model (GLM / Kimi); a value above the model's context overflows invisibly.
//   CONFIG_KV  pr-review.<backend>.maxTokens  (optional) override the per-backend output-token budget — a positive int clamped to [256, 32_768]. Defaults: workers-ai 8_192, anthropic/bedrock 4_096. A ceiling (non-reasoning models unaffected); raise it if a reasoning model truncates inside <think> before the JSON answer.
//   CONFIG_KV  pr-review.anthropic.model `anthropic/`-prefixed model id (e.g. anthropic/claude-sonnet-4-6) — BYOK via AI Gateway
//   CONFIG_KV  pr-review.anthropic.mode  "tools" | "json"  (default "tools")
//   CONFIG_KV  pr-review.bedrock.model   `bedrock/`-prefixed model id (e.g. bedrock/us.anthropic.claude-opus-4-6-v1) — BYOC via AI Gateway
//   CONFIG_KV  pr-review.bedrock.region  AWS region (default us-east-1)
//   CONFIG_KV  pr-review.bedrock.roleArn IAM role to AssumeRoleWithWebIdentity into — trust policy MUST pin `sub: pr-review:*`
//   CONFIG_KV  pr-review.style           "default" (verbose verdict-table) | "compact" (LGTM-header + 3-col emoji table)
//   CONFIG_KV  pr-review.compact-max     how many findings the `compact` layout lists inline before "…and N more" (positive int, clamp 1..100, default 7; no-op for `default`)
//
// No API key: the Workers AI binding is the auth. A "tools"-mode backend that
// returns no tool calls auto-retries once in "json" mode, so a model that
// silently drops tool-calling still produces a review. A json-mode answer that
// carries no parseable JSON (prose, or a value truncated inside a reasoning
// block) gets ONE blunt "JSON only" repair retry before it counts as failed.
//
// The per-domain fan-out is fault-ISOLATED: one reviewer whose model call fails
// (unparseable output, a transient 429) is dropped to zero findings and flagged
// in the engagement line — the review still ships with the domains that
// succeeded. The run only goes red when EVERY reviewer fails.
//
// --- Per-dispatch overrides (Action mode) -----------------------------------
//
// A dispatch MAY override the CONFIG_KV defaults per call via the run inputs —
// `agents`, `backend`, `modelId`, `region`, `roleArn`, `focusArea`. Absent, the
// CONFIG_KV defaults apply, so the everyday Webhook path is unchanged. This is
// the model-bake-off / per-PR-escalation path the former `multi-agent-review`
// run served: dispatch `backend: "bedrock"`, a `modelId` under test, and a
// `roleArn` to compare a model on a real PR without touching CONFIG_KV.
//
// Mode: Webhook mode (fires on every pull_request push, zero GHA minutes) AND
//       Action mode (a GHA workflow dispatches it with per-call overrides).
// DSL:  see specs/03-dsl.md (uses `config` + `github`).

import { Effect, Schema, Match, Option, Either } from "effect";
import {
  defineRun,
  step,
  sandbox,
  artifact,
  config,
  io,
  github,
  type ReadFileFailed,
  StepFailed,
  type Container,
  type WebhookPayload,
} from "@flare-dispatch/core";
import { awsAssumeRole, workspace } from "@flare-dispatch/core/primitives";
import {
  type BackendUnconfigured,
  backendConfigKey,
  capDiff,
  composeSystemPrompt,
  coordinate as engineCoordinate,
  DEFAULT_NAMESPACE,
  DEFAULT_REVIEW_SYSTEM_PROMPT,
  type Finding,
  guidelinesKey,
  type ModelCallFailed,
  namespacedKeys,
  parseBackend,
  resolveBackend,
  reviewDomain,
  riskTier,
  ReviewOutputSchema,
  stripDiffNoise,
  type StructuredOutputInvalid,
  type Tier,
} from "@flare-dispatch/review-agent";

// Local helper — true if the PR carries the given label.
const hasLabel = (payload: WebhookPayload, name: string): boolean =>
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
  payload.pull_request?.labels?.some((l: { name: string }) => l.name === name) ?? false;

// The domain-scoped reviewers, one per concern (blog: "up to seven
// domain-specific agents"). The risk tier selects which subset actually runs.
const FULL_AGENTS = [
  "security",
  "performance",
  "code-quality",
  "documentation",
  "release-management",
  "compliance",
  "agents-md",
] as const;
const LITE_AGENTS = ["security", "code-quality", "performance", "documentation"] as const;
const TRIVIAL_AGENTS = ["code-quality"] as const;

// Agent fan-out mode. `multi` (the default) fans out to the tier-scaled
// per-domain personas above — the historical behaviour. `single` runs ONE
// generalist reviewer covering every concern (the collapsed `multi-agent-review`
// run's single-agent shape, now through the same structured engine). The
// operator picks the mode via `pr-review.agents` CONFIG_KV; a dispatch overrides
// it per call via the `agents` input.
const GENERAL_AGENT = ["general"] as const;
const AGENT_MODES = ["single", "multi"] as const;
type AgentMode = (typeof AGENT_MODES)[number];
const DEFAULT_AGENT_MODE: AgentMode = "multi";

/** Narrow an arbitrary config string to a known agent mode, or the default. */
const parseAgentMode = (raw: string | undefined): AgentMode =>
  AGENT_MODES.includes(raw as AgentMode) ? (raw as AgentMode) : DEFAULT_AGENT_MODE;

/** Config namespace this run's backend + prompt keys live under (`pr-review.*`). */
const NS = DEFAULT_NAMESPACE;

// The run's output. `findings` becomes the check-run annotation set; the rest
// renders in the summary. Imported from the engine package so the run's
// `outputs` schema and the engine's return type are one source of truth. Each
// push re-reviews the full PR diff independently — the current run is
// authoritative (no carry-over from prior executions), so a fixed finding clears.
const ReviewOutput = ReviewOutputSchema;

/** Footer marker on every PR comment this run posts — for idempotent updates. */
const COMMENT_MARKER = "<!-- flare-dispatch: pr-review -->";

/**
 * The "view full logs" footer for a PR comment — a markdown link to this
 * execution's log viewer, which (since #137) also lists the reviewed
 * `pr-review.diff` artifact. `undefined` viewer URL (a deploy with no public
 * origin / no log-link key) → empty string, so the comment renders exactly as
 * before. `viewerUrl` is dispatcher-minted (tokened), never model-authored, so
 * it needs no sanitization.
 */
const viewerFooter = (viewerUrl: string | undefined): readonly string[] =>
  viewerUrl === undefined
    ? []
    : ["", `📋 [View full logs & reviewed diff ↗](${viewerUrl})`];

/**
 * Where `prepare-diff` writes the unified diff inside the container. Read back
 * in full via `sandbox.readFile` — `ExecResult.stdout` inlines only a 16KB
 * tail, which silently reviewed a sliver of any sizeable PR.
 */
const DIFF_FILE = "/tmp/pr-review.diff";

// --- Oxc grounding -----------------------------------------------------------
//
// Before the model fans out, run oxlint (the Oxc Rust linter — Vite/VoidZero,
// now inside Cloudflare) on the PR's changed files IN THE SAME container the
// diff was produced in, and prepend its findings to the reviewable text. The
// model then CONFIRMS / EXPANDS deterministic, pre-computed static-analysis
// results (with exact `file:line` anchors) instead of re-deriving lint-level
// issues from the diff alone — cheaper tokens, fewer hallucinated findings,
// citable anchors.
//
// Best-effort by construction: oxlint runs via `npx` (no image change), and ANY
// failure — no lintable files, fetch error, read error — degrades to an empty
// block, so the review proceeds ungrounded and never goes red on this step.

/** Where the oxlint run writes its findings inside the container. */
const OXLINT_FILE = "/tmp/pr-review.oxlint.txt";
/** oxlint version line fetched via `npx` — tracks the 1.x major. */
const OXLINT_VERSION = "1";
/** Cap the changed-file list passed to oxlint (bounds the command line). */
const OXLINT_MAX_FILES = 60;
/** Cap the grounding block prepended to the model context. */
const OXLINT_MAX_CHARS = 8000;
/** Extensions oxlint lints — the changed files worth scanning. */
const LINTABLE_EXT = /\.(?:m?[jt]sx?|cjs)$/;

/**
 * Pull the changed (added/modified) file paths from a unified diff's
 * `+++ b/<path>` headers, keep the oxlint-lintable ones, dedupe, and cap.
 * `/dev/null` targets (deletions) are dropped.
 */
const changedLintableFiles = (diff: string): readonly string[] => {
  const files = new Set<string>();
  for (const line of diff.split("\n")) {
    if (!line.startsWith("+++ ")) continue;
    const target = line.slice(4).trim();
    if (target === "/dev/null") continue;
    const path = target.startsWith("b/") ? target.slice(2) : target;
    if (LINTABLE_EXT.test(path)) files.add(path);
  }
  return [...files].slice(0, OXLINT_MAX_FILES);
};

/** Wrap raw oxlint output in a labelled, capped grounding block (or "" if empty). */
const groundingBlock = (raw: string): string => {
  const trimmed = raw.trim();
  if (trimmed.length === 0) return "";
  const body =
    trimmed.length > OXLINT_MAX_CHARS
      ? `${trimmed.slice(0, OXLINT_MAX_CHARS)}\n…(truncated)`
      : trimmed;
  return [
    "## Static analysis — oxlint findings on the changed files",
    "",
    "Deterministic, pre-computed by the Oxc linter. Treat as authoritative:",
    "confirm/expand these, cite their `file:line` anchors, and do NOT re-derive",
    "lint-level issues the linter already reports.",
    "",
    "```",
    body,
    "```",
  ].join("\n");
};

export const prReview = defineRun({
  name: "pr-review",
  version: "3.1.0",
  image: "registry.cloudflare.com/openhackersclub/flare-dispatch-review:latest",

  triggers: [
    {
      event: "pull_request",
      actions: ["opened", "synchronize", "ready_for_review"],
      // MUST match Action mode's semantic instanceId — `{run}:{repo_}:{sha12}`
      // (dispatch.ts `semanticInstanceId`) — so a repo that BOTH installs the
      // App (webhook mode) and dispatches from its own CI (Action mode)
      // collapses to ONE review per head SHA at the `create({id})` layer
      // instead of paying two sandbox runs. The PR number adds nothing to the
      // identity: the dispatcher's dedup contract is "same {run, repo, sha} →
      // same execution", and the number still rides in `inputs`.
      idempotencyKey: ({ payload }) =>
        `pr-review:${payload.repository.full_name.replace(/\//g, "_")}:${payload.pull_request.head.sha.slice(0, 12)}`,
      gate: ({ payload }) =>
        (!payload.pull_request.draft || hasLabel(payload, "request-ai-review")) &&
        !hasLabel(payload, "skip-ai-review") &&
        !payload.pull_request.user.login.endsWith("[bot]"),
      inputs: ({ payload }) => ({
        repo: payload.repository.full_name,
        sha: payload.pull_request.head.sha,
        baseSha: payload.pull_request.base.sha,
        pr: payload.pull_request.number,
        installationId: payload.installation.id,
      }),
    },
  ],

  inputs: Schema.Struct({
    repo: Schema.String,
    sha: Schema.String,
    baseSha: Schema.String,
    pr: Schema.Number,
    // Webhook mode maps it from `payload.installation.id`; Action mode omits
    // it. The run threads it to `github.pullReview` to authenticate the comment.
    installationId: Schema.optional(Schema.Number),
    // --- Per-dispatch overrides (Action mode) — all optional. When absent the
    //     CONFIG_KV defaults apply, so the Webhook path is unchanged. These ride
    //     the HMAC-authenticated dispatch (operator-trusted), never the diff. ---
    /** Override `pr-review.agents` — one generalist reviewer vs the persona fan-out. */
    agents: Schema.optional(Schema.Literal("single", "multi")),
    /** Override `pr-review.backend` — pin a backend for this dispatch (e.g. a bake-off). */
    backend: Schema.optional(Schema.String),
    /** Override the resolved backend's model id (e.g. the model under test). */
    modelId: Schema.optional(Schema.String),
    /** Override the bedrock backend's AWS region. */
    region: Schema.optional(Schema.String),
    /** Override the bedrock backend's IAM role ARN to AssumeRoleWithWebIdentity into. */
    roleArn: Schema.optional(Schema.String),
    /** Extra focus line appended to the reviewer system prompt for this dispatch. */
    focusArea: Schema.optional(Schema.String),
  }),

  outputs: ReviewOutput,

  limits: { maxDurationSec: 1500, maxConcurrency: FULL_AGENTS.length },

  // At most one review per PR per 30 minutes, across BOTH dispatch paths
  // (webhook + Action). A rapid push sequence on an active PR collapses to
  // the first dispatch of the window; the skipped pushes answer 202 with the
  // prior execution's id, so CI stays green. The LAST state of a busy PR
  // still gets reviewed on its next dispatch after the window — and a review
  // can always be forced by re-running the CI job ≥30 min later.
  cooldown: { seconds: 1800, scope: (input) => `pr-${input.pr}` },

  run: (input) =>
    Effect.gen(function* () {
      // This execution's log-viewer URL — links every PR comment (success OR
      // failure) back to the full logs + the reviewed `pr-review.diff` artifact.
      // `Option.none()` on a deploy with no public origin / log-link key, in
      // which case the comment renders link-less, as before.
      const viewerUrl = Option.getOrUndefined(yield* io.viewerUrl);
      // The whole review is wrapped in an error boundary that ALWAYS posts a PR
      // comment — success or failure. `reviewBody` produces the output; the
      // catch arm posts a "could not complete" comment and re-fails (as a
      // `StepFailed`, a member of `RunError`) so the check still goes red
      // honestly. The comment post itself is best-effort — a failure to post
      // must not mask the original cause.
      return yield* reviewBody(input, viewerUrl).pipe(
        Effect.catchAll((err) =>
          Effect.gen(function* () {
            const reason = describeError(err);
            // Post inside a step so a CF Workflow instance retry replays from
            // the checkpoint instead of posting a duplicate failure comment.
            yield* step("post-failure-comment", () =>
              postComment(
                input,
                [
                  `⚠️ **pr-review could not complete**: ${reason}`,
                  ...viewerFooter(viewerUrl),
                  "",
                  COMMENT_MARKER,
                ].join("\n"),
              ).pipe(
                Effect.catchAll((postErr) =>
                  io.log(
                    "warn",
                    `pr-review: failure-comment post failed — ${describeError(postErr)}`,
                  ),
                ),
              ),
            );
            return yield* Effect.fail(
              new StepFailed({ step: "pr-review", cause: reason }),
            );
          }),
        ),
      );
    }),
});

// ---------------------------------------------------------------------------
// The review proper.

const reviewBody = (input: RunInput, viewerUrl?: string) =>
  Effect.gen(function* () {
    // 1. Resolve the configurable backend (model id + output mode + diff cap)
    //    from CONFIG_KV, with any per-dispatch input overrides layered on top —
    //    FIRST, before paying for a container, so a misconfigured backend fails
    //    fast → the error boundary posts a PR comment naming the missing key. No
    //    API key — the model is called through the `modelGateway` capability
    //    (Workers AI binding via an AI Gateway), which the runtime provides
    //    ambiently.
    const resolved = yield* step("resolve-backend", () =>
      resolveEffectiveBackend(input),
    );

    // 2. Check out the PR head. `git` is in the image; no dependency install.
    const { container, dir: repoDir } = yield* step("checkout", () =>
      workspace({ repo: input.repo, sha: input.sha }),
    );

    // 3. Build the reviewable diff with plain `git` (no `review-agent` CLI).
    //    A non-zero exit FAILS the step (honest red check) — see `execOrFail`.
    //
    //    The diff is written to a FILE and read back with `sandbox.readFile`
    //    — NOT taken from `ExecResult.stdout`, which inlines only a bounded
    //    16KB tail (the rest streams to R2). Reading stdout silently reviewed
    //    just the last sliver of any sizeable PR: the risk tier under-counted
    //    (a huge PR classified `lite`/`trivial`), sensitive paths escaped the
    //    `full` escalation, and the reviewers "found nothing" because they
    //    never saw the change.
    //
    //    Noise-strip + backend-sized cap happen INSIDE the step so the value
    //    the Workflow checkpoints is bounded by `maxDiffChars`, never the raw
    //    multi-MB diff. One global cap sized for a frontier model would
    //    overflow a catalog model's context invisibly (the model goes
    //    needle-blind and "finds nothing") — hence the RESOLVED backend's cap.
    //
    //    THREE-dot diff (`base...head`), never two-dot: `baseSha` is the base
    //    branch TIP at event time (`pull_request.base.sha`), not the fork
    //    point. A two-dot endpoint diff on any PR behind its base reviewed
    //    `base-tip → head`, presenting everything merged to the base since
    //    the fork as DELETIONS — reviewers flagged phantom removals of files
    //    the PR never touched. Three-dot diffs from `merge-base(base, head)`,
    //    matching the PR diff GitHub itself renders.
    const diff = yield* step("prepare-diff", () =>
      execOrFail({
        container,
        cwd: repoDir,
        command: [
          "git",
          "diff",
          "--unified=3",
          `--output=${DIFF_FILE}`,
          `${input.baseSha}...${input.sha}`,
        ],
      }).pipe(
        Effect.andThen(sandbox.readFile({ container, path: DIFF_FILE })),
        Effect.map((raw) => capDiff(stripDiffNoise(raw), resolved.maxDiffChars)),
      ),
    );

    // 3b. Persist the FULL (uncapped) diff as an artifact so the log viewer can
    //     surface it and a reviewer can read exactly what was reviewed. The
    //     checkpointed `diff` value is capped for the model's context; the file
    //     on disk is the whole thing. Best-effort: a `git diff --output=<file>`
    //     emits no stdout, so without this the run leaves no visible record of
    //     the diff. A capture failure must never fail the review — swallow it.
    yield* step("upload-diff", () =>
      artifact.upload({
        name: "pr-review.diff",
        path: DIFF_FILE,
        container,
        contentType: "text/plain; charset=utf-8",
      }),
    ).pipe(
      Effect.catchAllCause((cause) =>
        Effect.logWarning(`upload-diff failed (non-fatal): ${cause}`),
      ),
    );

    // 4. Risk tier — a pure heuristic on diff size + touched paths (no model
    //    call). The tier is always classified (it sizes the diff cap + renders
    //    in the comment); the agent MODE decides whether it also scales the
    //    persona fan-out.
    const tier = yield* step("classify-risk", () => riskTier({ diff }));

    // 4b. Agent fan-out mode — `single` (one generalist reviewer) vs `multi`
    //     (the tier-scaled per-domain personas). Input override > CONFIG_KV >
    //     default. The plan's agent set follows from the mode.
    const agentMode: AgentMode =
      input.agents ??
      parseAgentMode(
        yield* step("resolve-agents", () => config.get("pr-review.agents")),
      );
    const plan = planForMode(agentMode, tier);

    // 5. The reviewer system prompt — layered base → guidelines → focus,
    //    composed here (not threaded through the engine) so `focusArea` rides
    //    the trusted dispatch input, never the attacker-controllable diff. The
    //    model's OUTPUT is still sanitized before it renders in the public
    //    comment regardless.
    //      - `pr-review.prompt`     REPLACES the base reviewer instruction.
    //      - `pr-review.guidelines` is ADDITIVE — appended on top as authoritative
    //        house rules (a suppression rubric, project conventions, severity
    //        calibration), so an operator can shape the review without forking
    //        the maintained default prompt.
    const promptOverride = yield* step("resolve-prompt", () =>
      config.get("pr-review.prompt"),
    );
    const guidelines = yield* step("resolve-guidelines", () =>
      config.get(guidelinesKey(NS)),
    );
    const systemPrompt = composeSystemPrompt({
      base: promptOverride ?? DEFAULT_REVIEW_SYSTEM_PROMPT,
      ...(guidelines !== undefined ? { guidelines } : {}),
      ...(input.focusArea !== undefined ? { focus: input.focusArea } : {}),
    });

    // 5b. Comment style preset — operator picks the comment layout. Both
    //     presets are hard-coded server-side (so model-authored text can't
    //     inject layout). See `parseStyle` + `renderReviewComment`.
    const style = parseStyle(
      yield* step("resolve-style", () => config.get("pr-review.style")),
    );

    // 5b-ii. How many findings the `compact` layout lists inline before the
    //        "…and N more" overflow line. Operator-tunable so the leaderboard
    //        layout can show more (or fewer) without a code change; clamps to a
    //        sane range, defaults to 7. No-op for the `default` layout, which
    //        uses its own MAX_RENDERED_FINDINGS.
    const compactMax = parseCompactMax(
      yield* step("resolve-compact-max", () => config.get("pr-review.compact-max")),
    );

    // 5c. When the resolved backend is `bedrock`, mint short-lived AWS creds via
    //     OIDC federation — the modelGateway Bedrock route SigV4-signs each call
    //     with these. The role's trust policy SHOULD pin `sub: pr-review:*` so
    //     a leaked HMAC alone can't assume the role; `awsAssumeRole`'s default
    //     subject is `<run>:<execution-id>`, which matches that pattern. Other
    //     backends skip this step entirely.
    const bedrockCreds =
      resolved.backend === "bedrock" &&
      resolved.roleArn !== undefined &&
      resolved.region !== undefined
        ? yield* step("assume-bedrock-role", () =>
            awsAssumeRole({
              roleArn: resolved.roleArn as string,
              region: resolved.region as string,
              sessionName: `pr-review-${input.sha.slice(0, 12)}`,
            }),
          )
        : undefined;
    const awsCreds =
      bedrockCreds !== undefined && resolved.region !== undefined
        ? {
            accessKeyId: bedrockCreds.accessKeyId,
            secretAccessKey: bedrockCreds.secretAccessKey,
            sessionToken: bedrockCreds.sessionToken,
            region: resolved.region,
          }
        : undefined;

    // 5d. Oxc grounding — run oxlint on the PR's changed files in the checkout
    //     container and prepend its findings to the reviewable text. Best-
    //     effort: any failure (no lintable files, npx fetch, read) degrades to
    //     the ungrounded diff via `catchAllCause`, so the review never goes red
    //     on this step. `riskTier` above used the RAW diff (size heuristic), so
    //     the grounding block doesn't perturb tier classification.
    const oxlintBlock = yield* step("oxlint-scan", () =>
      Effect.gen(function* () {
        const files = changedLintableFiles(diff);
        if (files.length === 0) return "";
        // Non-zero exit (oxlint found issues) is a NORMAL ExecResult, not a
        // failure; redirect both streams to a file read back in full (the
        // 16KB stdout tail would truncate a busy lint report).
        yield* sandbox.exec({
          container,
          cwd: repoDir,
          command: `npx --yes oxlint@${OXLINT_VERSION} ${files.join(" ")} > ${OXLINT_FILE} 2>&1`,
        });
        const out = yield* sandbox.readFile({ container, path: OXLINT_FILE });
        return groundingBlock(out);
      }),
    ).pipe(Effect.catchAllCause(() => Effect.succeed("")));
    const groundedDiff =
      oxlintBlock.length > 0 ? `${oxlintBlock}\n\n${diff}` : diff;

    // 6. Fan out one reviewer per domain, IN-WORKER, in parallel — only the
    //    agents this tier calls for. Each calls the model via the `modelGateway`
    //    capability (provided by the runtime, like `config`/`sandbox`); findings
    //    are Schema-validated tool-call / json output. The reviewers see the
    //    GROUNDED diff (oxlint findings prepended), not the raw diff.
    //
    //    FAULT-ISOLATED fan-out: each reviewer is wrapped in `Effect.either`, so
    //    one domain whose model call fails — unparseable output
    //    (`StructuredOutputInvalid`), a transient 429 under the concurrent
    //    fan-out (`ModelCallFailed`) — is dropped to zero findings and flagged
    //    `errored` in the engagement line, rather than aborting the whole review.
    //    The review still ships with every domain that DID succeed — resilience
    //    is the point of the multi-agent design. ONLY when EVERY reviewer fails
    //    do we re-raise (the typed cause), so the boundary posts an honest "could
    //    not complete" for a systemic fault (misconfigured backend, wrong
    //    model/mode, gateway down) instead of masking it as an empty review.
    const reviewed = yield* step("review", () =>
      Effect.gen(function* () {
        const results = yield* Effect.forEach(
          plan.agents,
          (agent) =>
            reviewDomain({
              agent,
              diff: groundedDiff,
              tier: plan.tier,
              model: resolved.model,
              backend: resolved.backend,
              mode: resolved.mode,
              maxTokens: resolved.maxTokens,
              systemPrompt,
              ...(awsCreds !== undefined ? { aws: awsCreds } : {}),
            }).pipe(Effect.either),
          { concurrency: plan.agents.length },
        );

        // Log each failed domain's cause (the per-reviewer error is otherwise
        // swallowed by the tolerance below) for operator diagnosis in the logs.
        for (let i = 0; i < plan.agents.length; i++) {
          const r = results[i];
          if (r !== undefined && Either.isLeft(r)) {
            yield* io.log(
              "warn",
              `pr-review: reviewer "${plan.agents[i]}" failed — ${describeError(r.left)}`,
            );
          }
        }

        // Every reviewer failed → no partial review to salvage; re-raise the
        // first cause (typed) so the error boundary names it precisely.
        const firstLeft = results.find(Either.isLeft);
        if (firstLeft !== undefined && results.every(Either.isLeft)) {
          return yield* Effect.fail(firstLeft.left);
        }

        // Findings from the domains that succeeded; failed domains contribute
        // none. Per-domain counts (or an `errored` flag) render in the comment so
        // an all-empty review is visibly "N reviewers each reported 0", and a
        // degraded review is visibly "this domain errored" — never silently
        // indistinguishable from "found nothing".
        const findings: ReadonlyArray<Finding> = results.flatMap((r) =>
          Either.isRight(r) ? r.right : [],
        );
        const domainCounts: ReadonlyArray<DomainCount> = plan.agents.map(
          (agent, i) => {
            const r = results[i];
            return r !== undefined && Either.isRight(r)
              ? { agent, count: r.right.length }
              : { agent, count: 0, errored: true };
          },
        );
        return { findings, domainCounts };
      }),
      // Cap the per-step retry. CF Workflows' default is `limit: 5` (up to 6
      // attempts), and a step retry REPLAYS the whole fan-out. This step throws
      // ONLY when EVERY reviewer failed — an all-fail is systemic: a 429 storm
      // from the concurrent burst (`concurrency: plan.agents.length`) hitting a
      // low-per-model-rate-limit model, a backend down, or a wrong model/mode.
      // The per-domain calls are NOT individually retried on 429, so replaying
      // the 7-way burst 5 more times just re-throttles the same model and
      // re-issues every call. One retry (after the backoff, by which the
      // per-minute rate window may have rolled over) keeps recovery for a
      // genuine transient while bounding the worst case from ×6 attempts to ×2.
      { retries: 1 },
    );
    const allFindings: ReadonlyArray<Finding> = reviewed.findings;
    const domainCounts: ReadonlyArray<DomainCount> = reviewed.domainCounts;

    // 7. Coordinate — PURE deterministic assembly (dedup + counts + verdict) over
    //    THIS run's findings. No model call; the current run is authoritative, so
    //    a fixed finding clears. `tier` is stitched in from the plan.
    const coordinated = yield* step("coordinate", () =>
      engineCoordinate({ findings: allFindings }),
    );
    const output = { ...coordinated, tier: plan.tier };

    // 8. Post the visible top-level PR review comment (the findings additionally
    //    land as check-run annotations via the run output). Best-effort — a
    //    comment failure must not turn a green review red.
    yield* step("post-comment", () =>
      postComment(
        input,
        renderReviewComment(input, output, domainCounts, style, viewerUrl, compactMax),
      ).pipe(
        Effect.catchAll((e) =>
          io.log("warn", `pr-review: posting PR comment failed — ${describeError(e)}`),
        ),
      ),
    );

    return output;
  });

// ---------------------------------------------------------------------------
// Helpers.

type RunInput = {
  readonly repo: string;
  readonly sha: string;
  readonly baseSha: string;
  readonly pr: number;
  readonly installationId?: number;
  // Per-dispatch overrides (Action mode) — see the `inputs` schema.
  readonly agents?: AgentMode;
  readonly backend?: string;
  readonly modelId?: string;
  readonly region?: string;
  readonly roleArn?: string;
  readonly focusArea?: string;
};

type Plan = {
  readonly tier: Tier;
  readonly agents: readonly string[];
};

/** Map the risk tier to its agent set. (The model is resolved from the backend
 *  config, not the tier — see `resolveBackend`.) */
const planForTier = (tier: Tier): Plan =>
  Match.value(tier).pipe(
    Match.when("trivial", () => ({ tier: "trivial" as const, agents: TRIVIAL_AGENTS })),
    Match.when("lite", () => ({ tier: "lite" as const, agents: LITE_AGENTS })),
    Match.when("full", () => ({ tier: "full" as const, agents: FULL_AGENTS })),
    Match.exhaustive,
  );

/** Map the resolved agent mode + risk tier to the reviewer plan. `single` runs
 *  ONE generalist reviewer (the tier still renders in the comment); `multi` runs
 *  the tier-scaled per-domain personas. */
const planForMode = (mode: AgentMode, tier: Tier): Plan =>
  mode === "single" ? { tier, agents: GENERAL_AGENT } : planForTier(tier);

/**
 * Resolve the active backend, layering per-dispatch input overrides over
 * CONFIG_KV. An override-aware `getConfig` shim feeds the engine's
 * `resolveBackend` the input values where present — so a single dispatch can
 * fully specify a backend (e.g. a Bedrock model bake-off: `backend: "bedrock"`,
 * `modelId`, `roleArn`, `region`) with NO CONFIG_KV keys set, while an everyday
 * webhook review with no overrides resolves exactly as before. The shim only
 * shadows the keys an override targets; everything else still reads CONFIG_KV.
 */
const resolveEffectiveBackend = (input: RunInput) =>
  Effect.gen(function* () {
    // The effective backend names which `<backend>.*` keys an override targets.
    const backend = parseBackend(
      input.backend ?? (yield* config.get(backendConfigKey(NS))),
    );
    const keys = namespacedKeys(NS)[backend];
    const overrides = new Map<string, string>();
    if (input.backend !== undefined)
      overrides.set(backendConfigKey(NS), input.backend);
    if (input.modelId !== undefined) overrides.set(keys.modelKey, input.modelId);
    if (input.region !== undefined && keys.regionKey !== undefined)
      overrides.set(keys.regionKey, input.region);
    if (input.roleArn !== undefined && keys.roleArnKey !== undefined)
      overrides.set(keys.roleArnKey, input.roleArn);
    return yield* resolveBackend(
      (key) =>
        overrides.has(key)
          ? Effect.succeed<string | undefined>(overrides.get(key))
          : config.get(key),
      { namespace: NS },
    );
  });

/**
 * Run a container command and FAIL the Effect when it exits non-zero. The core
 * `sandbox.exec` deliberately surfaces a non-zero exit as a normal `ExecResult`
 * (a failing test is data, not an error) — but for `pr-review`'s git steps a
 * non-zero exit IS a real failure that must turn the check red, so we lift it
 * into the typed error channel here (rather than mutating the shared
 * `sandbox.exec`, which other runs rely on).
 */
const execOrFail = (opts: {
  container: Container;
  cwd: string;
  command: readonly string[];
  timeoutSec?: number;
}) =>
  sandbox.exec(opts).pipe(
    Effect.flatMap((result) =>
      result.exitCode === 0
        ? Effect.succeed(result)
        : Effect.fail(
            new ExecNonZero({
              command: opts.command.join(" "),
              exitCode: result.exitCode,
              stderrTail: result.stderr.slice(-2000),
            }),
          ),
    ),
  );

/** A container command that ran to completion but exited non-zero. */
class ExecNonZero extends Schema.TaggedError<ExecNonZero>()("ExecNonZero", {
  command: Schema.String,
  exitCode: Schema.Number,
  stderrTail: Schema.String,
}) {}

/** Post a top-level PR review comment via the `github` capability. */
const postComment = (input: RunInput, body: string) =>
  github.pullReview({
    repo: input.repo,
    pr: input.pr,
    sha: input.sha,
    body,
    ...(input.installationId !== undefined
      ? { installationId: input.installationId }
      : {}),
  });

/** The tagged errors the review boundary knows how to describe precisely;
 *  anything else (e.g. `StepFailed`, a core capability error) falls to the
 *  `Match.orElse` arm. */
type DescribableError =
  | BackendUnconfigured
  | ModelCallFailed
  | StructuredOutputInvalid
  | ExecNonZero
  | ReadFileFailed;

/** Human-readable one-liner for any error the boundary catches. */
const describeError = (err: unknown): string =>
  Match.value(err as DescribableError).pipe(
    Match.tag(
      "BackendUnconfigured",
      (e) => `backend "${e.backend}" is misconfigured — set ${e.missing}`,
    ),
    Match.tag(
      "ModelCallFailed",
      (e) => `model call failed (${e.reason}): ${e.message}`,
    ),
    Match.tag(
      "StructuredOutputInvalid",
      (e) =>
        `model returned unparseable ${e.surface} output (${e.reason}); the backend may need \`mode: "json"\` or a different model` +
        // Surface a short, SANITIZED excerpt of the RAW model text. It is
        // captured on the error but was previously dropped here, so the only
        // record of what the model actually returned lived in the (auth-gated)
        // step logs — which is exactly why an `empty` (the model's answer
        // silently dropped at the binding boundary) took so long to diagnose.
        // `sanitizeModelText` defangs it; the verdict never derives from it.
        (e.excerpt.trim() !== ""
          ? ` — model returned: "${sanitizeModelText(e.excerpt)}"`
          : ""),
    ),
    Match.tag(
      "ExecNonZero",
      (e) => `\`${e.command}\` exited ${e.exitCode}`,
    ),
    Match.tag(
      "ReadFileFailed",
      (e) => `reading the diff file \`${e.path}\` failed: ${e.message}`,
    ),
    Match.orElse(() =>
      err instanceof Error ? err.message : JSON.stringify(err),
    ),
  );

/**
 * Neutralize model-authored text before it renders in the public PR comment.
 * The diff is attacker-controllable on a hostile PR and feeds the model, so a
 * finding's `title`/`message`/`path` could carry `@mention` pings, raw HTML, or
 * code-fence/backtick break-outs. Collapse to one line, drop angle brackets,
 * defang `@` and backticks, and bound the length. (Control flow is already safe
 * — the verdict derives only from the schema-constrained `level`.)
 */
const SANITIZE_MAX = 500;
// U+200B zero-width space — inserted after `@` it breaks GitHub's @mention
// autolink without visibly altering the text. Built from a code point so the
// source stays ASCII-only.
const ZWSP = String.fromCharCode(0x200b);
const sanitizeModelText = (s: string): string =>
  s
    .replace(/[\r\n]+/g, " ")
    .replace(/[<>]/g, "")
    .replace(/`/g, "'")
    .replace(/@(?=[\w-])/g, `@${ZWSP}`)
    .slice(0, SANITIZE_MAX);

/** One domain reviewer's engagement — how many findings it reported, or
 *  `errored: true` when its model call failed and it was skipped (count 0). */
type DomainCount = {
  readonly agent: string;
  readonly count: number;
  readonly errored?: boolean;
};

/** How many findings render in the comment; the rest land as check annotations. */
const MAX_RENDERED_FINDINGS = 25;

/** Severity → icon + label, shared by the summary table and per-finding headings.
 *  Labels mirror the header's count names (critical / warnings / suggestions). */
const severityBadge = (level: Finding["level"]): string =>
  Match.value(level).pipe(
    Match.when("failure", () => "🛑 Critical"),
    Match.when("warning", () => "⚠️ Warning"),
    Match.when("notice", () => "💡 Suggestion"),
    Match.exhaustive,
  );

/**
 * GitHub blob URL for a finding — `https://github.com/<repo>/blob/<sha>/<path>#L<n>`.
 * `repo`/`sha` come from the trusted webhook input; `path` is model-authored, so
 * each segment is sanitized then URL-encoded (plus manual paren-encoding —
 * `encodeURIComponent` leaves `()` alone, and a bare `)` would terminate the
 * markdown link). The line fragment is dropped when the model's line numbers
 * are nonsense (≤ 0), leaving a plain file link.
 */
const findingUrl = (repo: string, sha: string, f: Finding): string => {
  const encodedPath = sanitizeModelText(f.path)
    .replace(/^\/+/, "")
    .split("/")
    .map(encodeURIComponent)
    .join("/")
    .replace(/\(/g, "%28")
    .replace(/\)/g, "%29");
  const start = Math.floor(f.startLine);
  const end = Math.floor(f.endLine);
  const fragment = start > 0 ? (end > start ? `#L${start}-L${end}` : `#L${start}`) : "";
  return `https://github.com/${repo}/blob/${sha}/${encodedPath}${fragment}`;
};

/** `path:line` display text for a finding's location. Square brackets are
 *  stripped on top of `sanitizeModelText` — the text renders inside `[…](url)`
 *  link syntax, where a `]` would break out of the link. */
const findingLoc = (f: Finding): string => {
  const path = sanitizeModelText(f.path).replace(/[[\]]/g, "");
  return f.startLine === f.endLine
    ? `${path}:${f.startLine}`
    : `${path}:${f.startLine}-${f.endLine}`;
};

/** Sanitized text safe inside a markdown table cell — an unescaped `|` would
 *  split the row. */
const tableCell = (s: string): string => sanitizeModelText(s).replace(/\|/g, "\\|");

/** Comment style preset — operator selects the layout via `pr-review.style`
 *  CONFIG_KV. Hard-coded presets only; never an arbitrary template (model-
 *  authored text would otherwise be able to inject layout). */
const STYLES = ["default", "compact"] as const;
type Style = (typeof STYLES)[number];
const DEFAULT_STYLE: Style = "default";

/** Narrow an arbitrary config string to a known style, or fall back to default. */
const parseStyle = (raw: string | undefined): Style =>
  STYLES.includes(raw as Style) ? (raw as Style) : DEFAULT_STYLE;

/** Default for how many findings the `compact` style lists (most-critical
 *  first) when `pr-review.compact-max` is unset. The full set is still emitted
 *  as check-run annotations regardless of style; this only bounds how many
 *  appear inline in the PR comment before the "…and N more" overflow line.
 *  Mirrors typical "leaderboard" PR-review bots (7 most-critical first). */
const COMPACT_MAX_LISTED_DEFAULT = 7;

/** Upper clamp on the operator-configured compact cap — a comment listing
 *  hundreds of findings inline would blow past sensible PR-comment length and
 *  GitHub's body limit; the overflow → check-run annotations is the relief
 *  valve. 100 is well above any real review. */
const COMPACT_MAX_LISTED_CLAMP = 100;

/** Parse the `pr-review.compact-max` config (how many findings the `compact`
 *  layout lists inline). Accepts a positive integer string; clamps to
 *  1..{@link COMPACT_MAX_LISTED_CLAMP}; falls back to
 *  {@link COMPACT_MAX_LISTED_DEFAULT} for unset / non-numeric / ≤0 values, so a
 *  typo never silently drops the comment to zero rows. */
const parseCompactMax = (raw: string | undefined): number => {
  if (raw === undefined) return COMPACT_MAX_LISTED_DEFAULT;
  const n = Number.parseInt(raw.trim(), 10);
  if (!Number.isInteger(n) || n <= 0) return COMPACT_MAX_LISTED_DEFAULT;
  return Math.min(n, COMPACT_MAX_LISTED_CLAMP);
};

/** Render the consolidated review as a markdown PR comment. `style` picks the
 *  layout: `default` is the verbose verdict-table for power users; `compact` is
 *  the lean leaderboard-bot format ("`## ✅ LGTM`" + 3-col emoji table). */
const renderReviewComment = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  domainCounts: ReadonlyArray<DomainCount>,
  style: Style = DEFAULT_STYLE,
  viewerUrl?: string,
  compactMax: number = COMPACT_MAX_LISTED_DEFAULT,
): string =>
  Match.value(style).pipe(
    Match.when("compact", () => renderCompact(input, output, viewerUrl, compactMax)),
    Match.when("default", () =>
      renderDefault(input, output, domainCounts, viewerUrl),
    ),
    Match.exhaustive,
  );

/** Verbose verdict-table layout: summary table + per-finding details + per-domain
 *  engagement line. Full reviewer transparency — the historical default. */
const renderDefault = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  domainCounts: ReadonlyArray<DomainCount>,
  viewerUrl?: string,
): string => {
  const verdictBadge = Match.value(output.verdict).pipe(
    Match.when("approve", () => "✅ Approve"),
    Match.when("comment", () => "💬 Comment"),
    Match.when("request-changes", () => "🛑 Request changes"),
    Match.exhaustive,
  );

  const header = [
    `### AI code review — ${verdictBadge}`,
    "",
    `Risk tier: \`${output.tier}\` · ${output.critical} critical · ${output.warnings} warnings · ${output.suggestions} suggestions`,
    "",
    // Engagement line: every domain that ran, with its finding count — the
    // counts may exceed the deduped totals above. A domain whose model call
    // failed (and was skipped) shows `⚠️` instead of a count, so a degraded
    // review is visibly partial rather than silently under-reporting.
    `Reviewers: ${domainCounts.map((d) => (d.errored === true ? `${d.agent} ⚠️` : `${d.agent} ${d.count}`)).join(" · ")}`,
  ];

  const rendered = output.findings.slice(0, MAX_RENDERED_FINDINGS);

  const summaryTable = [
    "",
    "| # | Severity | Change required | Location |",
    "| --- | --- | --- | --- |",
    ...rendered.map(
      (f, i) =>
        `| ${i + 1} | ${severityBadge(f.level)} | ${tableCell(f.title)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) |`,
    ),
  ];

  const details = rendered.flatMap((f, i) => [
    "",
    `#### ${i + 1}. ${severityBadge(f.level)}${sanitizeModelText(f.title)}`,
    "",
    `📍 [${findingLoc(f)}](${findingUrl(input.repo, input.sha, f)})`,
    "",
    sanitizeModelText(f.message),
  ]);

  const findingsBlock =
    output.findings.length === 0
      ? ["", "_No findings._"]
      : [
          ...summaryTable,
          ...details,
          ...(output.findings.length > MAX_RENDERED_FINDINGS
            ? [
                "",
                `_…and ${output.findings.length - MAX_RENDERED_FINDINGS} more (see check annotations)._`,
              ]
            : []),
        ];

  return [
    ...header,
    ...findingsBlock,
    ...viewerFooter(viewerUrl),
    "",
    COMMENT_MARKER,
  ].join("\n");
};

/** Compact "leaderboard-bot" layout — verdict header (`## ✅ LGTM` /
 *  `⚠️ Minor Issues` / `🚫 Changes Requested`) + 3-col emoji table, max 7 rows.
 *  Built to match the format teams already get from in-house reviewers like
 *  opencode-agent so a FlareDispatch comment lands without a layout cliff. */
const renderCompact = (
  input: Pick<RunInput, "repo" | "sha">,
  output: Schema.Schema.Type<typeof ReviewOutput>,
  viewerUrl?: string,
  compactMax: number = COMPACT_MAX_LISTED_DEFAULT,
): string => {
  const verdictHeader = Match.value(output.verdict).pipe(
    Match.when("approve", () => "## ✅ LGTM"),
    Match.when("comment", () => "## ⚠️ Minor Issues"),
    Match.when("request-changes", () => "## 🚫 Changes Requested"),
    Match.exhaustive,
  );

  if (output.findings.length === 0) {
    return [
      verdictHeader,
      "",
      "No issues found.",
      ...viewerFooter(viewerUrl),
      "",
      COMMENT_MARKER,
    ].join("\n");
  }

  const compactSeverity = (level: Finding["level"]): string =>
    Match.value(level).pipe(
      Match.when("failure", () => "🔴"),
      Match.when("warning", () => "🟡"),
      Match.when("notice", () => "🔵"),
      Match.exhaustive,
    );

  const rendered = output.findings.slice(0, compactMax);
  const overflow = output.findings.length - rendered.length;

  return [
    verdictHeader,
    "",
    "| Severity | Location | Issue |",
    "| --- | --- | --- |",
    ...rendered.map(
      (f) =>
        `| ${compactSeverity(f.level)} | [${tableCell(findingLoc(f))}](${findingUrl(input.repo, input.sha, f)}) | ${tableCell(f.message)} |`,
    ),
    ...(overflow > 0
      ? ["", `_…and ${overflow} more (see check annotations)._`]
      : []),
    ...viewerFooter(viewerUrl),
    "",
    COMMENT_MARKER,
  ].join("\n");
};
§05 / Platform

More than runs — the platform underneath.

05.01 / Agentic review

Review runs that read the whole diff

pr-review reads every diff in-Worker — single- or multi-agent — and posts one verdict with a summary table, per-finding headings, and blob links. Re-reviews auto-resolve what you've fixed.

05.02 / Writeback

Propose the fix back as a PR

A run returns a changed-files manifest from its credential-free container; the Dispatcher validates it and opens a PR. Best-effort — a failure annotates the green check, never flips it red.

05.03 / Model routing

Provider-agnostic model gateway

Route model calls to Workers AI (incl. DeepSeek reasoners), Anthropic, or Bedrock — Bedrock via OIDC → STS → SigV4, no long-lived AWS keys. Swap the backend from KV config, no redeploy.

05.04 / Admission control

Bursts queue, they don't fail red

A global FIFO queue caps in-flight runs at the pool size — work beyond capacity drains in order instead of erroring. Per-container leases serialize same-container runs.

05.05 / Session replay

Scrub the walkthrough inline

product-demo drives a deployed app over CDP with native session recording; reviewers replay it in an rrweb player, linked from the check summary and the result email.

05.06 / Pure webhook mode

Run with zero GitHub Actions

Install the App, set one secret, and its webhook deliveries fire runs on every PR — no .github/workflows file, no GHA minutes, no HMAC to rotate. Onboarding a repo is just installing the App.

05.07 / Observability signals

Bring your own APM, vendor-blind

ci-triage-pr never queries your APM. A tiny consumer-side adapter (Datadog, SigNoz, HyperDX, or your own) prints the normalized signals/v1 shape — any vendor works on day one, no observability key in the Dispatcher.

05.08 / Email notify

Results land in the inbox, not just the PR

A run can email a completion summary over Cloudflare Email Routing, rrweb replay one click away. Opt-in and default-secure: a no-op until send_email is configured, with an optional recipient allowlist.

§06 / Dogfood

Dogfooded on this repo.

FlareDispatch reviews its own pull requests and records its own walkthroughs. Each artifact below is the real output of a run on OpenHackersClub/flare-dispatch — two of the recipes shown above, fired on this codebase.

pr-review 💬 Comment

The review lands on the pull request

pr-review reads every file in the diff in-Worker, dedups across domain reviewers, and posts one verdict comment — a severity summary table, per-finding headings, and GitHub blob links — on every PR to this repo. This is a real review it left on the PR that added this very section.

Real FlareDispatch pr-review verdict comment from flaredispatch[bot] on a flare-dispatch pull request: an 'AI code review — 💬 Comment' header, a risk-tier line, a per-domain reviewer breakdown, a severity summary table, and per-finding details linking to GitHub blob lines.
product-demo Walkthrough GIF

The walkthrough drops back as a GIF

product-demo drives the deployed app over CDP with native session recording, stitches the per-chapter frames into one ≤10 MB walkthrough GIF, and posts it inline on the PR — alongside an rrweb replay linked from the check run.

Animated walkthrough GIF recorded by FlareDispatch product-demo, posted inline as a PR comment on the flare-dispatch repo.
§07 / Design philosophy

Three bets the design is built on.

  1. §07.01

    Progressive offloading.

    Move CI to Cloudflare one job at a time — the heaviest, most expensive jobs first. GitHub Actions keeps the cheap jobs and the PR gate; nothing is all-or-nothing.

  2. §07.02

    Runs are typed programs, not YAML.

    A run is an Effect-TS program — Schema-typed inputs and outputs, composable steps, tagged errors, exhaustive matching. Fork it, vendor-edit it, unit-test it without booting a container.

  3. §07.03

    BYOC by construction.

    Every code path assumes the run is deployed in your own Cloudflare account — no multi-tenant operator, no hosted SaaS to trust. The specs are the contract; the runs ship at HEAD and are yours to fork.

§08 / Read on

Live at HEAD. The specs are the contract; the runs are shipped.