Deterministic, AI-driven development flows.
Orca allows you to programmatically define software development workflows where AI agents perform the coding. If you want AI-generated code to always be reviewed by another agent, don't try to coerce the agents; just express that requirement in code. Don't waste tokens on formatting, committing, or creating PRs - all of this can be handled by an ordinary script.
Orca comes with an orca cli, which can be used interactively by humans, or
headlessly by humans and agents alike. A number of built-in flows, implementing
e.g. a plan-implement-review loop, allow you to start using Orca right away.
Orca flow scripts are written in Scala, and can be run with a single command
through scala-cli, which is installed by the
orca installer. No other dependencies are needed - everything is automatically
bootstrapped. Scala 3 looks like Python, but with types - so you get quick
feedback if your flow script has any problems.
Orca's development flows are resumable, so that if work is interrupted mid-flow for any reason, it can be continued from the last commit.
You can use Orca to orchestrate development in any language and ecosystem.
Orca assumes that it has configured, logged-in access to Claude, Codex,
OpenCode, or Pi (depending which backend you use), as well as gh and git.
Install with one command, which installs scala-cli (via its official
installer) if you don't have it already, and writes the orca executable to
~/.local/bin/orca:
curl -fsSL https://raw.githubusercontent.com/VirtusLab/orca/master/install.sh | bashSee Orca Shell for the details and the full command-line
reference, or just run orca / orca help.
Interactively: install the CLI, run orca, pick a flow (implement.sc
comes first in the list) and enter your prompt. Non-interactively, use orca run <flow> "<prompt>". See Orca Shell for installation and the full
command-line reference.
[!WARNING] Orca is designed to work in a sandboxed environment! Coding agent tool usage is auto-approved by default (
tools = ToolSet.Full,autoApprove = AutoApprove.All): write-capable turns let the agent edit files and run shell commands without prompting. This can be changed by changing the flow's options in code. Alternatively, use a VPS or local sandbox such as Sandcat, Docker Sandboxes, or any other.
Driven by an agent (headless): a coding agent or harness invokes the CLI non-interactively to implement a change, e.g. from CI or as a sub-task of another agent:
orca run implement.sc "add a rate limiter to /login"Useful flags: --branch <name> (name the branch the run creates),
--skip-branch (continue on the current branch instead of creating one),
--keep-changes (leave uncommitted files in place instead of
stashing them) and --worktree (run in a git worktree of this repository
instead of the current checkout).
In every mode, which agent (and model) handles the planning, coding, and review
roles comes from settings.properties — written for you by the shell's
first-run wizard or orca config, hand-editable too; see Settings.
Agents can load skills/orca to know when
and how to delegate here; in Claude Code, /orca [prompt] asks which flow to run
and where, then starts it — installable as a Claude Code plugin, a Pi package, or
by symlinking into any harness's skills directory; see its
README for specifics.
As a script: run a flow directly with scala-cli, no install required — see
An example flow.
scala-cli run --workspace "$(mktemp -d)" implement.sc -- "add a rate limiter to /login"Save this as implement.sc and run it with your prompt:
//> using scala 3.9.0
//> using dep "org.virtuslab::orca:0.1.10"
//> using jvm 21
import orca.{*, given}
// Roles (planning / coding / review) come from settings.properties —
// per-project `.orca/settings.properties`, else ~/.config/orca/settings.properties,
// else claude for everything. Bodies can still name a concrete harness
// (`claude`, `codex.mini`, …) where a flow wants one — details under "Coding
// agent tools".
flow(OrcaArgs(args)):
// `stage` is the committing, resumable unit of work. The plan is produced in
// one agentic turn and recorded in the progress log; a re-run with the same
// prompt skips this stage and reads the stored Plan back.
val plan = stage("Plan"):
Plan.autonomous.from(userPrompt, planningAgent).value
// One stage per task: each stage commits its work + a progress-log entry as
// one commit. Completed stages are skipped on resume — re-running the same
// prompt picks up from the first incomplete task. A session is keyed by the
// stage that mints it, so each task gets its own, seeded with the plan's
// brief (which primes it on first use, and is replayed if the backend session
// is lost on resume).
for task <- plan.tasks do
stage(s"Task: ${task.title}"):
val session = codingAgent.session("implementer", seed = plan.brief)
session.run(task.description)
reviewThenFix(
coderSession = session,
reviewers = allReviewers(reviewAgent),
// One review round, one fix turn. Reviewers are picked by a picker LLM
// on reviewAgent.cheap (see "Review utilities"); format and lint
// default to the project's stack settings
// (`.orca/settings.properties`, auto-discovered on first run) — see
// "Settings" below. The whole task goes in: reviewers are shown its
// title and description, plus the run's prompt, each labelled.
task = task
)
// Each task's single pass took the fixer's word for its own fixes; this loop
// over everything the run changed is what checks them.
val openFindings = stage("Final review"):
reviewAndFixLoop(
coderSession = session,
reviewers = allReviewers(reviewAgent),
task = Task(Title("The whole planned change"), plan.brief),
diff = ReviewDiff.WholeRun,
maxFixTurns = 5
)
// Best effort: opens a PR when the checkout is on a GitHub `gh` can reach,
// and says why in one line when it isn't. What the loop left open is listed
// in the PR body.
openPrIfGitHub(
summarisingAgent = codingAgent.cheap,
openFindings = openFindings
)scala-cli run --workspace "$(mktemp -d)" implement.sc -- "Add a rate-limiter to the /login endpoint"Each flow starts by creating a feature branch, named by a short
cheap-model-generated label derived from the prompt (slugged; pass branchNaming = ... to override, or --branch <name> to name it yourself). On success the flow opens a PR when the repository is on a
GitHub gh can reach, and hands you back the branch you started on — the work
is on the PR. Otherwise it says so in one line and leaves you on the feature
branch, ready to test or open a PR by hand — see The flow
lifecycle for the full success/failure/resume behavior.
If the flow is interrupted — user intervention or an intermittent error — just run the same command again: it resumes from the last committed set of changes, so only a small amount of work is repeated. Orca borrows ideas from durable computing: which stages have completed, and with what results, is tracked in a progress file committed alongside the modified code, making commits the unit of atomicity — the progress log can't drift from the changes in the repository. When the flow is done, the progress log is removed from the branch in one last commit, which is pushed too if the flow had already pushed the branch.
There are two runnable examples under
examples/runnable/:
- 01-simple (in-memory plan + review, autonomous planner),
- 02-interactive (same shape as 01, but the
planner can ask clarifying questions via
ask_user).
More flow scripts — issue-pr.sc, issue-pr-bugfix.sc,
implement-enhanced.sc, review.sc — live in flows/; run them
against your own git repo.
For convenient editing of Orca flow scripts, with code-completion, you can try the Metals VSCode extension.
The words this README uses. Orca's internals have their own, in AGENTS.md.
Flows and runs
- flow — a Scala script whose body is
flow(OrcaArgs(args)): ...; see An example flow. - flow args —
OrcaArgs: the prompt and the command-line flags; see Command-line usage. - prompt — the user's input text,
userPromptin a flow body. - stage —
stage(name)(body): a unit of work that commits on completion and is skipped on resume; see Flow methods. - plan task — one
orca.plan.Taskof aPlan. The plan brief (Plan.brief) is the planner's codebase briefing; see Data structures. - run — one prompt's flow execution, across every process it takes to finish; see The flow lifecycle.
- attempt — one of those processes: one
orca run, oneflow(...)call. - re-run / resume — another attempt of an unfinished run, with the same prompt; it skips the stages already recorded.
- progress log —
.orca/runs/<key>.progress.json, committed with each stage: which stages finished, and their results. - run target — where a run works: a new branch (the default), the current
branch (
--skip-branch) or a worktree (--worktree). - worktree — a second checkout of the repository under
.orca/worktrees/.
Agents and conversations
- harness (also backend) — the coding-agent CLI orca drives:
claude,codex,opencode,piorgemini. - agent — a harness with a model and tool settings (
claude,codex.mini,codingAgent, …); see Built-in tools. - role agent —
planningAgent,codingAgentorreviewAgent, resolved from settings; see Coding agent tools. - cheap tier —
agent.cheap: the harness's cheaper model; see Sessions. - turn — one prompt to an agent and its reply.
- one-shot / chat / session —
agent.run(one turn),agent.chat()(a conversation for this attempt) andagent.session(name, seed)(a conversation that survives resume). A conversation is the history the harness keeps across turns. Sessions says which to use. - session name / session key — the name is the session's role
(
"implementer"), whichorca continuematches; the key is the name plus the stage the session is created in. - seed / re-seed — the context a session starts from, usually the plan brief. A session whose conversation is lost is re-seeded: started again from its seed; see Sessions.
- structured output —
resultAs[O]: a reply parsed into anO, which needs aJsonData[O]; see Built-in tools.
Review
- reviewer — a reviewer prompt paired with a read-only agent.
- reviewer catalog —
reviewerCatalog: every reviewer a run can use; see Settings. - roster — the reviewers one review call is given.
- review round — one pass of the picked reviewers, the lint gate and any checks over the change; see Review utilities.
- fix turn — the coder session's
.runthat fixes a round's findings;maxFixTurnscaps how many. - finding / declined / open finding — a problem a reviewer, the lint gate or a check reported; one the fixer refused, with a reason; one the review ends without resolving. See Data structures.
- gate — a stack command:
format,lintortest. The lint gate runs each review round. - stack settings — the project's gate commands, from
.orca/settings.properties; see Settings. Configured— how a review call takes a gate: from settings (the default), off, or a given value; see Review utilities.OpenFindings— what a review returns: the findings it left open; see Data structures.
Safety
- capability — a compile-checked token a call needs:
InStage(agent calls) andWorkspaceWrite(git,ghand file writes) come from astage(...)body,FlowControl(starting stages, minting sessions) from theflow(...)body.FlowContext(reads) is not one. See capabilities. ToolSet— which tools an agent has:ReadOnly,NetworkOnly,Full(the default) orNoTools. Enforcement is how strictly each harness holds that limit; see Coding agent tools.
The following are available inside a flow(...) { ... }.
The five coding agents — claude, codex, opencode, pi, gemini — share
one call surface. Durable: session(name, seed): FlowSession →
.run(prompt) / .resultAs[O].run(input). One-shot: run(prompt),
resultAs[O].{autonomous,interactive}.run(input). Ephemeral multi-turn:
chat(): Chat → .run(prompt) / .resultAs[O]...run(input). Common tuning:
withModel, withCheapModel, withAutoApprove, withSystemPrompt, withName,
withReadOnly, withNetworkOnly, withSelfManagedGit. The table lists each
backend's model accessors and backend-specific extras:
| Tool | Backend-specific methods | Purpose |
|---|---|---|
claude |
haiku/sonnet/opus/fable, cheap (→ haiku), withModel(Model), withNetworkTools |
Claude Code coding/reviewing agent. Bare claude is Opus with the 1M-token context window (the coder; reviewers share it); use claude.sonnet/claude.haiku for cheap one-shot calls, or claude.fable for the hardest ones. interactive mode lives only on resultAs[O]. See Sessions for durable (session) vs ephemeral (run/chat). |
codex |
mini, cheap (→ mini), withModel(Model) |
OpenAI Codex coding/reviewing agent. Bare codex pins GPT-6 Sol (needs a codex CLI that offers it); use codex.mini (GPT-6 Luna) for cheap one-shot calls. |
opencode |
anthropicOpus/anthropicSonnet/anthropicHaiku, openaiAstra/openaiSol/openaiLuna, cheap (provider-matched: openai→luna, else anthropicHaiku), withModel(providerModel) / withModel(provider, modelId) |
OpenCode coding/reviewing agent, driven over HTTP+SSE against a headless opencode serve (started lazily, shared for the attempt; sessions survive it — see Sessions). Spans providers, so models are provider-qualified: use an accessor (opencode.openaiLuna) or opencode.withModel("openai/gpt-5-mini") / opencode.withModel("ollama", "llama3.1"). Inherits the user's configured opencode providers/auth. |
pi |
withModel(Model) |
Pi coding agent backend, driven through pi --mode rpc. Pi handles provider/model selection through its own CLI configuration; pin a model with pi.withModel(Model("provider/model")). Interactive calls can ask clarifying questions via Orca's ask_user bridge. |
gemini |
flash, cheap (→ flash), withModel(Model) |
Google Gemini CLI coding/reviewing agent, driven via gemini --output-format stream-json. Bare gemini pins Gemini 3.1 Pro (preview); use gemini.flash (Gemini 3.8 Flash) for cheaper one-shot calls. Structured output is prompt-enforced (Gemini has no schema flag); withReadOnly maps to --approval-mode plan. See ADR 0015. |
git |
push, head, headCommit, isAncestorOfHead, branchExists, isIgnored, uncommittedDiff, changedFiles, reviewChanges, pendingChanges, diffVsBase, defaultBase, show, fileAt |
Git reads against the working tree, plus push. The runtime owns the run's branch and commits, so branch switching and committing are not on git. Commits and branch names are typed (orca.gitref.CommitHash, orca.gitref.BranchName); head answers the branch HEAD is on or the commit it is detached at (orca.gitref.Head). Recoverable failures (NoDefaultBase, PushFailure — NonFastForward/RemoteDeclined, GitReadFailed) surface as Either; .orThrow converts a Left back to an exception when the case is unexpected. uncommittedDiff covers the whole repository minus .orca/ bookkeeping, tracked files only, and is empty once the work is committed — diffVsBase is the branch-wide view. reviewChanges is what reviewAndFixLoop hands reviewers: that diff plus the contents of files new to the repo, together with the list of every path in the change set, how much of each changed, and each file's own part of the diff. It takes an optional commit to compare against (headCommit reads one) so work already committed still shows up. changedFiles is the path list on its own, for a consumer gating on file names — the diff text alone names neither a binary change nor a rename, and leaves a trailing tab on a path containing a space. pendingChanges describes what the next commit will include: a --stat summary, the new files, and the diff. isIgnored answers false when git cannot answer. |
gh |
availability, createPr, updatePr, readIssue, readIssueComments, readPrComments, writeComment(pr, body) / writeComment(issue, body), upsertComment(pr, marker, body) / upsertComment(issue, marker, body), buildStatus, waitForBuild |
GitHub PR + CI integration via the gh CLI. availability is a read-only probe of whether a PR can be opened from this checkout, answering with a GitHubAvailability. createPr is idempotent by branch (returns the existing PR if one is open); upsertComment finds a prior comment carrying marker and edits it in place (see Authoring rules for the re-run pattern). updatePr replaces a PR's title + body. waitForBuild returns Either[BuildWaitFailed, …]. |
fs |
read, write, list |
Working-tree file I/O. read returns Option[String] so a missing file is a branch point, not an exception. write refuses a path outside the working tree or under .orca/runs, .orca/cache or .orca/worktrees. |
The runtime owns git: every write-capable agent turn is told not to commit,
push, or switch branches — it edits the working tree; the runtime commits each
stage and owns the run's branch, and the flow pushes via git.push. Opt out per-tool with
claude.withSelfManagedGit.
For the LLM interfaces, resultAs[O] defines the shape of the structured
output. The O type needs a JsonData[O] (provided by derives JsonData on a
case class) for schema generation and deserialization. A parameterless enum that
derives JsonData travels as its case name, and the schema lists every name; a
sum type whose cases carry fields cannot be an O. Additionally, you might
define an Announce[O] so that a friendly summary is printed in the event log,
instead of a raw json.
A minimal Pi-backed flow looks the same; Pi reads your normal Pi configuration:
flow(OrcaArgs(args)):
stage("Run"):
val session = pi.session("run", seed = userPrompt)
session.run(userPrompt)There are two ways to drive a model in a flow:
- The role agents —
planningAgent/codingAgent/reviewAgent. Backend-agnostic: each is resolved from settings (see Settings), defaulting to claude. UseplanningAgentforPlan.*calls,codingAgentfor the implementer's durable session, andreviewAgentforallReviewers(...)and the review machinery's defaults. Edit settings and the whole flow follows; you never name a backend in the body. - A specific agent + model —
claude.opus,codex.mini,opencode.openaiLuna. Use a concrete accessor when you want a particular backend or tier regardless of settings — sayclaude.opusfor a step that must have the strongest model even where the coding role is a cheaper backend. None of the shipped flows do this; they all follow the roles. The tier accessors (.opus/.sonnet/…) live on the concrete agents, not on the role accessors — socodingAgent.opuswon't compile; that's the cue to name the backend. Pin any other model withwithModel(Model("…")).
Two axes constrain an agent. ToolSet is which tools exist
at all:
// ReadOnly — reads only, no shell, no edits (reviewers, plan review, brief).
val reviewer = claude.withReadOnly
// NetworkOnly — reads plus read-only network (web, and on claude a host-served
// GitHub issue/PR read), for planners that must read an issue/PR. How strongly
// each backend blocks edits varies — see the enforcement matrix in AGENTS.md.
val planner = claude.withNetworkOnly
// Full (the default) — write-capable.Prompting (autoApprove) is which of the available tools auto-approve
without a y/n prompt — only meaningful for interactive turns, and consulted only
on Full:
// Restrict auto-approval to a named tool set (honoured by claude).
val limited =
claude.withAutoApprove(AutoApprove.Only(Set("Read", "Edit", "Grep")))AutoApprove.Only fits interactive flows, where a human answers anything
outside the set; an autonomous turn has no one to approve, so an out-of-set call
blocks. Only claude enforces the set per tool — codex and gemini have no
per-tool granularity, so there Only widens to full auto-approve. For an
unattended run the practical boundary is a sandbox:
Sandcat, Docker
Sandboxes, or any other.
Orca's agents are ordinary harness sessions — claude, gemini, codex,
opencode or pi — started in your repository. They load the same instruction
files (~/.claude/CLAUDE.md, CLAUDE.md, CLAUDE.local.md, AGENTS.md,
GEMINI.md, …), MCP servers, plugins and hooks as your own sessions.
Autonomous turns have no one to answer prompts (see above), so:
- Coding turns auto-approve everything by default (
withAutoApprovenarrows it). - On claude, read-only roles (planner, reviewers, reviewer picker) deny any
tool outside orca's list, your MCP tools included, unless your claude
settings
permissions.allowthem. - On claude, opencode and pi, cheap one-shots (branch names, default commit messages) run with no tools and no MCP servers.
Check your instructions for:
- Mandatory tool calls. "Always call X first" needs X allowed (see above), or write "if available".
- A human in the loop. In autonomous flows, "ask me before X" or "wait for confirmation" cannot work: no one is there to answer.
Top-level, available via import orca.*:
| Method | Signature | Use |
|---|---|---|
flow(args, ...)(body) |
flow(args: OrcaArgs, branchNaming?, stackSettings?, planningAgent?, codingAgent?, reviewAgent?)(body) |
Entry point. Creates one feature branch + one progress log for the run. The three role agents (below) resolve from settings — see Settings — defaulting to claude; planningAgent/codingAgent/reviewAgent here are per-role programmatic overrides (Some(_.claude.opus)) that win over both settings files. Branch naming defaults to a short cheap-model-generated label (slugged); pass branchNaming = Some(BranchNamingStrategy.issue(handle)) to override (e.g. for issue flows). stackSettings = Some(StackSettings(...)) pins the run's stack settings — the settings file's stack portion is then neither read nor written (the escape hatch for a language-specific flow; its agent keys are still honoured). See The flow lifecycle for the full branch/teardown behavior. |
planningAgent (in-body accessor) |
planningAgent: Agent[?] |
The planning-role agent, resolved from settings — see Coding agent tools. Hand it to Plan.*. |
codingAgent (in-body accessor) |
codingAgent: Agent[?] |
The coding-role agent — the run's primary: implementer sessions, branch naming, stack discovery, default commit messages. |
reviewAgent (in-body accessor) |
reviewAgent: Agent[?] |
The review-role agent: allReviewers(reviewAgent), the reviewer-picker and the lint summariser default to its tiers. |
stage[T: JsonData](name, commitMessage?)(body) |
(name: String, commitMessage: Option[T => String] = None)(body): T |
The committing, resumable unit of work. On success, records the result, force-adds the progress log, and commits (code changes + log delta = one commit). On re-run, a stage whose result is still recorded is skipped and the stored value is returned. T must have JsonData — case class Foo(...) derives JsonData is enough. Commit message defaults to a codingAgent.cheap summary of the diff; override via commitMessage. |
display(message) |
(message: String): Unit |
Progress-only output: no stage, no commit, no log entry. Callable anywhere — outside a stage or inside a fork. |
Par.mapUnordered(n)(items)(f) |
(parallelism: Int)(items: Seq[A])(f: A => R): List[R] |
The sanctioned script fan-out (no Ox import needed). Ephemeral agent turns (codingAgent.run, chat.run) work inside f; the durable, flow-thread-only operations (stage, codingAgent.session, session.run) throw if called from a fork. Results arrive in completion order. |
fail(message) |
(message: String): Nothing |
Abort with a message. Triggers failure teardown: stays on the feature branch so a re-run resumes. |
Any tool (except git) or agent flow(...) builds by default can be replaced
by a named argument. Plain tools take the value directly (gh = Some(myGh), interaction = Some(myInteraction) — your own orca.backend.Interaction implementation,
e.g. for Slack; not exported from orca.*, so import it by its full path).
Agents take a factory that receives the attempt's AgentWiring (event sink,
interaction, workDir, prompts), so a tuned agent lands on the same dispatcher
as the defaults:
flow(OrcaArgs(args), claude = Some(w => ClaudeAgents.default(w).opus))Factories exist for all five backends: ClaudeAgents.default(w),
CodexAgents.default(w), GeminiAgents.default(w), PiAgents.default(w), and
OpencodeAgents.default(w, launcher) — opencode's factory is applied where the
run's Ox scope exists (it pins a shared opencode serve to the scope), so its
slot is typed AgentWiring => Ox ?=> OpencodeAgent.
git has no override because the runtime owns the run's branch and commits
through it. The backend SPI is internal: a new coding-agent harness is added as
an orca module.
Every side-effecting call — git.push,
fs.write, gh writes, every agent.*.run — must happen inside a stage
body, and the compiler enforces it: a mutation outside a stage doesn't
compile. Pure reads (git.uncommittedDiff, git.changedFiles, gh.readIssue,
gh.availability, fs.read),
display, and fail run anywhere; agent.session(name, seed) runs inside or
outside a stage — it records a session, not a side effect. Where to
place effects is covered by the Authoring rules.
Two words this section leans on: a run is one prompt's flow execution, across
however many processes it takes to finish it; an attempt is one of those
processes — one orca run, or one flow(...) call. An interrupted run is
resumed by attempting it again with the same prompt.
Each run is bound to exactly one feature branch and one progress log
(.orca/runs/<key>.progress.json, where <key> is derived from the prompt):
- Start: stash a dirty working tree with a warning (recover with
git stash pop); create + checkout the feature branch; write and commit the progress log header.--branch <name>(OrcaArgs.branch) names that branch, winning overbranchNaming; a protected or already existing name is refused rather than renamed. The three flags below reach a flow as oneOrcaArgs.target(RunTarget), which has no case for a combination orca refuses. A script can also set that field itself —flow(OrcaArgs(args).copy(target = RunTarget.Worktree))— which overrides whatever the flags said.--skip-branch(RunTarget.CurrentBranch) binds the run to the CURRENT branch instead of creating one — for continuing work already planned on a branch — refusing on a protected branch or detached HEAD. On a FRESH--skip-branchrun a dirty tree is tolerated, not stashed: uncommitted or untracked files (e.g. plan files left by a planning harness) stay in place for the flow, and get swept into the first stage's commit.--keep-changes(Uncommitted.Keepon either branch case) does the same on a FRESH run in either branch mode — in normal mode the files survive branch creation and reach the new branch in that first stage commit. With neither flag, a dirty tree on a fresh run is put to the user: stash (the default), keep, or abort; with no terminal to ask, it stashes. A run that already has a progress log — a resume, or one too broken to read — always stashes and ignores--keep-changes, so an interrupted stage's partial work can't leak into the stage that re-runs.--worktree(RunTarget.Worktree) runs the whole flow in.orca/worktrees/<hash>of this repository — a second checkout, keyed on the same prompt hash as the progress log, created on the first attempt and reused by every later attempt for that prompt. It isolates the run: two runs never share a checkout or a branch. Uncommitted work does NOT come along — a worktree is made from a commit — so--worktreeis refused with--skip-branchand with--keep-changes:RunTarget.Worktreecarries neither a branch mode nor anUncommitted, so the pair is refused while argv is parsed and has no representation after that. The first attempt in a worktree pays a cold build (no build outputs, no dependencies, none of the untracked local config a project may need), an editor or indexer that ignores.gitignorewill see the second checkout, and orca never removes it. The run starts on anorca-worktree-<hash>branch orca also never deletes, so full cleanup isgit worktree remove .orca/worktrees/<hash>andgit branch -d orca-worktree-<hash>; a re-run of the prompt refuses rather than moving that branch if it has gained commits since. - Resume: a re-run with the same prompt finds the progress log and resumes
from the first incomplete stage (a
--branchnaming a different branch than the log's is refused). It says once which branch it bound, how many stages are already recorded, and that the interrupted stage's uncommitted work was not carried over; every durable session it re-enters throughsession.runis told the same — a re-seeded one in its preamble, a still-live one once, on its first turn (a conversation continued throughsession.chatis not told). A corrupt or truncated progress log is detected at startup — orca warns and starts fresh (previous stages re-run) rather than silently mis-resuming. - Success teardown: remove the progress-log file in a final commit, and push
it when the remote branch still carries the log (i.e. the flow pushed). A
throwaway feature branch (no substantive changes vs the starting branch) is
deleted and HEAD returns to the starting branch. Otherwise the feature branch
is kept, and where HEAD lands follows the run: a run that created a branch and
opened a PR hands you back the branch you started on (the work is on the
PR). Every other run leaves you where you were — on the feature branch when no
PR was opened or under
--skip-branch, and untouched under--worktree, where the work is in the separate checkout the summary names. The run then closes by naming the branch you are left on, the PR it opened if it opened one, how many files changed since the commit it started from, and thegit diffthat shows them. - Failure teardown: while HEAD is on the feature branch, discard the
failed stage's uncommitted partial edits —
git reset --hardfor tracked files, plusgit clean -fdfor the files it newly created; stay on the feature branch so a re-run resumes in place. Gitignored paths and.orca/are never removed. Whether the clean runs at all is decided once, at setup, for the whole run: a FRESH run that kept a dirty tree instead of stashing it (--skip-branch,--keep-changes, or the interactive keep answer) leaves orca unable to tell those files apart from the run's own — no untracked file is deleted, in any stage, including ones the failed stage created. Kept edits to tracked files that no stage has committed yet are put back after the reset; a re-run stashes them before it resumes. When the failed body left HEAD elsewhere, teardown touches nothing and says so.
Two files, both plain key = value lines, parsed once per attempt before setup:
{workDir}/.orca/settings.properties— committed, hand-editable project settings: the stack commands (format/lint/test) and, per role, which agent to use.$XDG_CONFIG_HOME/orca/settings.properties, defaulting to~/.config/orca/settings.properties(also on macOS) — a per-user default, agent keys only. An absent global file is simply skipped.
Precedence, code always winning over files:
- Roles:
flow(planningAgent = ...)(andcodingAgent/reviewAgent) programmatic override > project file > global file > built-in default (claude, no model pin). - Stack commands:
reviewAndFixLoop(formatCommands = Use(...)/Off)>flow(stackSettings = Some(...))> project file > auto-discovery (which writes the file).
An unreadable or malformed file — project or global — aborts the run before any tree mutation; the global file may contain ONLY agent keys, so a stack key there is also an error.
Stack commands. Keys format, lint, and test. Each value is one shell
command, run via bash -c in the flow's working directory; everything after the
first = is command text (lint = FOO=bar cargo check works). Repeating a key
appends: its commands run in file order, so a multi-stack repo lists one
line per stack half. A key's value may also be the literal off, which
explicitly disables that gate; a missing key has the same runtime effect (the
gate is skipped) but, unlike off, does not count as "configured" — see
Auto-discovery below. # lines are comments; commenting out a line is the same
as deleting it. A typical discovered project file:
# orca settings — edit freely, commit with the project.
# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery.
# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both.
# Cargo.toml; via rustfmt
format = cargo fmt
# Cargo.toml
lint = cargo check --tests
# no test config found
test = offAgent keys. planningAgent, codingAgent, and reviewAgent, valid in both
files, single-valued (a repeated agent key is an error). Value grammar:
harness[:model], split at the first : so a model id containing : survives;
harness is one of claude, codex, opencode, pi, gemini (an
unrecognised name is an error naming the valid set). The model part is passed
verbatim to the harness's withModel — orca does not normalise or validate
model ids, except that claude's bare haiku alias is sent as
claude-haiku-4-5, so a claude:haiku pin cannot land on a pricier tier when
the CLI resolves the alias. For example:
planningAgent = claude:opus
codingAgent = codex:gpt-5-mini
reviewAgent = opencode:anthropic/claude-haiku-4-5Agent keys are read even when flow(stackSettings = Some(...)) overrides the
stack commands — that override governs the stack portion only, and a malformed
project or global file still aborts the run either way. setup announces the
resolved roles and where each came from:
agents: planning=claude:claude-opus-5-5[1m] (default), coding=codex:gpt-5-mini (project), review=opencode:<harness default> (global)
<harness default> marks a role where nothing pins a model, so the harness
picks one itself.
Auto-discovery. Discovery runs when the project file is absent or has no stack line; discovered entries are appended below any existing content, so agent lines are never touched. Delete the stack lines (or the whole file) to re-run it. Discovery spends one cheap-model, read-only agent call inspecting the repo, then writes the file and announces every guess in the event log:
no .orca/settings.properties — discovering how to format, lint & test this project
format = cargo fmt # Cargo.toml; via rustfmt
lint = cargo check --tests # Cargo.toml
warning: stack settings: no test command — gate disabled
written to .orca/settings.properties — review and edit as needed.
Runs with an existing, stack-complete file — the steady state, including CI — make no model call.
Reviewer prompts. Reviewers come from three tiers, read once per attempt before setup like the settings files:
{workDir}/.orca/reviewers/*.md— committed project reviewers.$XDG_CONFIG_HOME/orca/reviewers/*.md, defaulting to~/.config/orca/reviewers/— your own, across every project.- The eight reviewers orca ships with.
A reviewer's identity is its filename stem — .orca/reviewers/orca.md is the
reviewer orca — compared case-insensitively. A file whose stem matches a
lower tier replaces it, keeping its position in the catalog; anything else is
appended, sorted by name. Project beats global beats built-in, so
.orca/reviewers/scala-fp.md retunes the shipped scala-fp for this project
without changing how many reviewers run. A reviewer that adds a new name joins
both allReviewers and minimalReviewers; one that shadows a shipped reviewer
runs wherever that shipped reviewer runs, so shadowing scala-fp leaves
minimalReviewers — correctness, clarity, tests — alone. The picker narrows
per task as usual.
Each file is frontmatter plus a body, the same shape the shipped ones use:
---
description: Checks the project's own layering rules.
files: \.scala$
---
## Scope
Review only the layering of the changed files...description: is required and must be a single line — the reviewer-picker
decides from it. The value is the rest of that line: a YAML block scalar (>,
|, >-, |-) or a value wrapped onto the next line aborts the run.
files: is optional: a regex matched against each changed path, so the
reviewer is only offered when the change touches a file it applies to. The body
is the reviewer's system prompt. A name: key, if present, is ignored.
README.md and any _-prefixed name sit in the directory as documents. Every
other .md must parse as a reviewer: a missing or unterminated frontmatter
block, a missing description:, an empty body, an invalid files: regex, or
two files claiming one name abort the run before any tree mutation, naming every
bad file at once — a reviewer silently dropped from the catalog would read as a
clean review. A symlinked prompt aborts too, but only in .orca/reviewers/:
that directory is committed and orca runs against repos it did not write, while
the global tier is your own config home and is read through links like
settings.properties beside it. When a tier contributes
anything, the run says so:
discovered reviewers: orca (project); scala-fp (project, shadows built-in)
Discovery internals and the .orca/ directory
Every discovered command cites the file that evidences it, and two checks run
before the file is written: the command's executable must be on PATH, and the
cited evidence file must exist. A command failing either is kept only as a
comment (# skipped: lint = just check (just: not found on PATH)), never run
silently; a key left with no command gets a live key = off line. A discovery
failure (backend unavailable, invalid output) aborts the run rather than writing
a "gates off" file.
.orca/ is committed by default: settings and each run's progress log
(runs/<key>.progress.json) ride the branch, while machine-local state lives
under .orca/cache/, which writes its own .gitignore: each run's durable
session records (runs/<key>.sessions.json, whose backend ids mean nothing in
another checkout), and per attempt a manifest
(attempts/<id>.manifest.json, what orca continue lists) and a cost log
(attempts/<id>.cost.jsonl: one line per agent turn with agent, role, model,
stage, token usage and cost — the per-agent and per-model detail the closing
summary leaves out). The cache is safe to delete; only the newest 20–40
attempts are kept. If
your .gitignore covers all of .orca/, every attempt warns to remove that line so
settings can be committed — the cache stays ignored on its own.
Within a flow body the resolved stack settings are available as
summon[FlowContext].stackSettings — a StackSettings(format, lint, test: List[String]). The test commands are not consumed by reviewAndFixLoop (the
lint gate stays deliberately cheap); they're there for a flow's own verification
stages.
Every way to talk to an agent, by what the conversation must do:
| Call | Conversation | Survives crash/resume | Mode | Output | Needs | In a fork |
|---|---|---|---|---|---|---|
agent.run(prompt) |
new, one turn | no | autonomous | text | InStage |
yes |
agent.resultAs[O].{autonomous,interactive}.run(input) |
new, one turn | no | both | O |
InStage |
yes* |
agent.chat() → chat.run(prompt) / chat.resultAs[O].{autonomous,interactive}.run(input) |
new, then continued by every turn | no | both | text or O |
InStage |
yes* |
agent.session(name, seed) → session.run(prompt) / session.resultAs[O].run(input) |
named; continued, or re-seeded if lost | yes | autonomous | text or O |
FlowContext, FlowControl, InStage, WorkspaceWrite |
no |
session.chat → as Chat |
the session's; refused while the backend doesn't hold it (never run, or lost on resume) | no (turns not recorded) | both | text or O |
InStage |
yes* |
Plan.{autonomous,interactive}.* → WithChat; .reviewed(), .chat |
new planning conversation, continued by .reviewed() and .chat |
no | as named | O |
FlowContext, InStage |
yes* |
reviewAndFixLoop / reviewThenFix |
new reviewer chats; continues coderSession |
the coder session does | autonomous | findings | FlowContext, FlowControl, InStage, WorkspaceWrite |
no |
lint(commands, agent) |
new (or continues a Lint.summariser) |
no | autonomous | ReviewResult (LintReport with a summariser) |
FlowContext, InStage |
yes |
* Interactive turns share your terminal: run them one at a time, never from parallel forks.
Use:
-
agent.run/agent.resultAs[O]for a one-shot question. -
agent.chat()for follow-ups within this attempt, including inside forks (each fork mints its own). -
agent.session(name, seed), on the flow thread, for work that edits the tree and must pick up after a crash. -
session.chatto continue a durable conversation from a fork, once the session has run on the flow thread (a chat can't seed it). -
resultAs[O].interactiveon an agent or a chat when a human steers the turn.session.runhas no interactive mode, since a steered turn can't be replayed from a seed; steer a session's conversation throughsession.chatafter it has run. -
Durable —
agent.session(name, seed). A get-or-create keyed by thenameand the stage the call sits in, returning aFlowSessionhandle that survives crash/resume: the same key resumes the same session (with a warning if this call's seed differs, rather than silently resuming the wrong one).nameis the role, and whatorca continue <name>matches. The stage half is implicit — a per-task loop mintsimplementerinside each task's stage and gets one session per task, with nothing to name them by hand. Two stages can therefore never reach one conversation, and minting one name twice in the same stage is an error rather than silent sharing: give each its ownstage(...), or rename one. Rename the stage and the key moves with it, so a re-plan that rewords a task gives that task a fresh session primed from the seed rather than resuming the old wording's conversation. Mint it where it is used: inside the stage that drives it, or outside every stage when several stages share one session. What you cannot do is return a handle from one stage as its result and drive it in a later one —FlowSessionhas noJsonData. Minting and running both happen on the flow thread. The record behind the handle is machine-local, not branch history: it lives in.orca/cache/, so the stage that minted it can fail and its retry still resumes the same conversation. -
Ephemeral —
agent.chat(). AChathandle continuing one conversation across.runcalls within this attempt only — no seeding, no persistence. Its calls need only the sharedInStagecapability, so chats work inside aPar.mapUnorderedfork: parallel reviewers each holding a multi-turn conversation is the canonical use.session.chatis a durable session's conversation as an ephemeral chat (one live continuation at a time).chat.withAgent(f)continues the same conversation on a variant of the chat's agent —_.withReadOnly,_.cheap,_.withName("…")— for turns that need other tools, a cheaper model or their own cost line. The variant must be built from the chat's agent (a different backend is refused).
val session = agent.session("implementer", seed = plan.brief)
session.run(task.description)
val chats = Par.mapUnordered(4)(reviewers): r =>
val c = r.chat()
c.run(s"review the diff: $diff")
c // keep the conversation for a later re-review turnThe seed is the essential context to rebuild the agent — typically the plan
brief, or the issue body when there is no brief. A fresh session is primed
with it on first use; if the backend lost the conversation on resume, the
session is re-seeded (with a warning: history is gone, only the seed plus a
preamble naming completed stages are rebuilt), while a live session continues
with its full history — told once, on this attempt's first turn against it, that the
tree holds only what earlier stages committed, since a conversation a previous
attempt opened remembers writing files that are no longer there.
How long a session should live. A backend conversation is re-sent whole on every API call it makes, so what a session costs grows with everything it has already done. Scope one to a unit of work — a task, a review stage — not to the run: the shipped flows mint a session per task and another for the final review, and each new one is primed from its seed and the completed-stage preamble.
agent.cheap returns the backend's cheap/fast variant (claude → haiku, codex →
mini, gemini → flash, opencode → anthropicHaiku, others → self) — used by the
runtime for branch naming and default commit messages.
Backend swaps across attempts. If a settings edit changes a role's agent
between attempts (e.g. codingAgent = codex becomes codingAgent = claude), a
session recorded under the old backend isn't resumed against the new one —
orca mints a fresh session from the seed and warns.
Mutations outside a stage body are compile errors (see Side effects happen inside stages). The rules below are the structural conventions you choose to follow as a flow author.
-
Reads outside, mutations inside. Only side-effecting work goes in a stage. Pure reads (
git.uncommittedDiff,gh.readIssue,fs.read,gh.waitForBuild) run outside stages — staging them wastes commits and checkpoints.agent.session(name, seed)is neither — it records a session — so put it where the session is used (see Sessions). -
Push lives in a later stage than the edit that produced it. A stage commits only on completion: a
git.push()in the same stage as the edit would push nothing (the edit isn't committed yet). The push must be in a separate, later stage:stage("Write failing test"): session.run("Write the failing test …") // commits on completion val pr = stage("Push + open PR"): // LATER stage — the test commit exists now git.push().orThrow gh.createPr(title = …, body = …).orThrow
-
One commit per stage. Each stage produces exactly one commit (code changes + the progress-log entry), made by the runtime when the stage completes.
-
Idempotent external effects, each in its own stage. Put each PR-open, comment-post, or push in a dedicated stage so it's checkpointed.
gh.createPris idempotent by branch (an open PR is reused, not duplicated) andgh.upsertComment(target, marker, body)edits a prior comment carryingmarkerin place — so if a crash re-opens the stage on resume, the re-run reuses the PR/comment instead of duplicating it. UseorcaCommentMarker(userPrompt, purpose)so the marker is unique to this run. -
Name stages descriptively. The stage name appears in the event log, the commit message (when no override is provided), and the progress preamble on resume. A name like
"Push + open PR"lets a reader (and the resuming agent) understand the checkpoint without reading code.
Orca gates side effects behind three capability tokens. You normally never
construct one — stage(...) bodies provide them, and a missing token is a
compile error with a message telling you where the call belongs:
| Capability | Kind | Gates | Provided by | Misuse caught by |
|---|---|---|---|---|
InStage |
shared (caps.SharedCapability) |
LLM runs (agent.*.run, session.run) |
stage(...) bodies |
missing-given compile error |
WorkspaceWrite |
exclusive (caps.ExclusiveCapability) |
git/gh writes, fs.write, progress-log writes |
stage(...) bodies |
missing-given compile error + a runtime owner-thread check (never cross a fork) |
FlowControl |
exclusive (caps.ExclusiveCapability) |
starting stages, minting sessions | the flow(...) body (not forks) |
missing-given compile error + a runtime owner-thread check |
(FlowContext — reads and display — is deliberately not a capability:
it is thread-safe and forks receive it freely.) A helper that starts stages
declares (using FlowContext, FlowControl).
The runtime always guards this at run time — a fork that calls
stage(...)/session(...) or makes a workspace write fails immediately, a second flow(...) in the same
working tree is refused, an agent used after its flow ended throws — so you get
the safety without any setup.
Compile-time checking (Scala's experimental capture checking)
The shared/exclusive split is capture checking vocabulary. Beyond the always-on runtime guards, enforcement moves to compile time in two more places:
-
Inside the library: orca's own parallel code (the reviewer fan-out) is compiled under capture + separation checking, so a change that captured a
WorkspaceWriteinto that fan-out would not compile (pinned by a compile-time test suite). -
Opt-in, in your script: add the two language imports to have the compiler check your code too — today that enforces, e.g., that a custom
ReviewerSelector's per-round function stays pure:import language.experimental.captureChecking import language.experimental.separationChecking
Full fork-boundary checking in scripts arrives when Ox itself adopts capture checking; until then the runtime guard covers that case.
The imports cost nothing when omitted — scripts without them compile and run identically (see ADR 0018 §6).
Available via import orca.plan.*:
The planning entry points form a mode × operation grid. The two axes are
orthogonal — every combination is valid. Mode is picked at the call site
(Plan.autonomous.* vs Plan.interactive.*), mirroring how Agent itself
splits autonomous / interactive:
| Operation | Result | autonomous (read-only + network, no human) |
interactive (agent can ask_user) |
|---|---|---|---|
from(userPrompt, agent, instructions?) |
Plan |
plan in one agentic turn | drive the planner conversationally |
assessThenPlan(userPrompt, agent, instructions?) |
Verdict[Plan] |
assess, then Proceed(plan) or Rejection(kind, body) |
same, but can ask the reporter to clarify instead of rejecting |
triage(report, agent, instructions?) |
Triage |
classify a bug report (not-a-bug / untestable / testable) | same, with clarifying questions |
Every cell returns WithChat[<result>] — the result paired with the
(ephemeral) Chat that produced it. Continue that conversation in-run
(chat.run(task); continuations have write access), or .value it and start a
fresh, durable implementer session via agent.session("implementer", seed = plan.brief) — the chat does not survive a crash/resume, so every
shipped example takes .value. Destructure when you want both: val WithChat(chat, plan) = Plan.autonomous.from(...).
From a WithChat[Plan], an optional .reviewed() step refines the plan
before implementing — the planner critiques its own draft, read-only, producing
an improved Plan. Chain it: Plan.autonomous.from(...).reviewed().value.
.reviewed(variant = _.cheap) runs the review on a variant of the read-only
planner.
assessThenPlan returns a Verdict: Verdict.Proceed(plan) to implement, or
Verdict.Rejection(kind, body) — a follow-up question, critique, or rebuff the
caller surfaces back to the reporter. triage returns a Triage sum type the
caller pattern-matches (NotABug / Untestable / Testable).
Available via import orca.review.*:
| Method | Use |
|---|---|
lint(commands, agent, instructions?) |
Run shell lint commands (in order, each via bash -c; every one runs even if an earlier one fails) and have agent summarise their labelled, concatenated output as a ReviewResult. Short output is inlined into the prompt; anything larger is written to a file under .orca/cache/ for the agent to read, so unbounded output can't overflow the context. |
lint(commands, summariser, instructions) |
As above, but summarising into an existing Lint.summariser(agent) conversation instead of a fresh one per call, so a gate run several times within one stage resumes the session rather than re-establishing it each round. Stop reusing a summariser once it has reported: it can repeat those findings on a later call whose commands no longer show them. reviewAndFixLoop does this for you. |
reviewAndFixLoop(coderSession, reviewers, task, userRequest?, ..., formatCommands?, lint?, checks?, maxFixTurns?, fixInstructions?) |
Run reviewers against task: Task, collect their findings, hand them to the coderSession (a FlowSession) to fix, re-evaluate. Reviewers are asked to report only what they believe should be fixed, and every finding they report reaches the fixer — nothing filters them in between. Reviewers see the task's title and description under separate labels, plus the user's request — the run's prompt by default, or userRequest when the prompt is only a pointer, like an issue reference. Keeping them apart is what lets a reviewer report a finding against the planner's choice rather than only against the code. A flow with no planning stage passes its prompt as the title and an empty description. Halts when reviewers come back clean, the fixer reports no fixes, or maxFixTurns fix turns have run (default 3, so up to four review rounds). Every exit names the findings it leaves open and why each is still open. Whatever is still open at that point — the findings the fixer declined, didn't account for, or that were first reported in the round that hit the cap — comes back in the returned OpenFindings with a reason. formatCommands: Configured[List[String]] runs before each review round; lint: Configured[Lint] runs alongside the reviewers each round — both default to the project's stack settings, see below. checks: List[ReviewCheck] (default none) run after formatting and before the reviewers, see below. |
reviewThenFix(coderSession, reviewers, task, userRequest?, formatCommands?, lint?, checks?) |
One round of the above and, if it found anything, one fix turn — then done. Nothing re-reviews a reviewer finding, so the fixer's claim that it fixed one is taken on trust; the lint gate and checks are the exception, re-run over the fixer's edits and given one more fix turn if they still fail, so a check can run three times in one pass. Reviewers are picked once (ReviewerSelector.agentDriven) and the change set is the enclosing stage's, as above. What the fixer declined, what it never reported on, and what the lint gate or a check still fails on, come back in the returned OpenFindings with a reason. Use it per task where a later stage reviews the same code again — a whole-run reviewAndFixLoop, below — and pay for the loop where nothing else re-reviews the fixes. |
ReviewCheck |
A check written in Scala — a benchmark, an HTTP probe, a scripted assertion: name plus evaluate(): ReviewResult. Pass it in reviewAndFixLoop's or reviewThenFix's checks; its findings go to the fixer with the reviewers'. |
OpenFinding.custom(title, reason, location) |
An open finding a flow records itself — say, a gate it runs outside the loop still failing. Add it to OpenFindings for the PR body, or pass it in priorOpenFindings so a loop's reviewers see it. |
allReviewers(base) |
Every reviewer in the run's catalog (the eight canonical ones — code-functionality, test, readability, code-structure, simplicity, performance, security, scala-fp — plus whatever .orca/reviewers/ and the global tier add, see Settings) as ReviewerAgents: each one its Reviewer definition plus a read-only agent built from base. |
minimalReviewers(base) |
Universally-applicable subset (code-functionality, readability, test) plus every discovered reviewer, same shape. Pair with the default LLM-driven selector when the full set is overkill. |
reviewerCatalog (in-body accessor) |
The run's resolved reviewer definitions — .all and .minimal are what the two above build from. Filter it to pick a subset yourself. |
reviewAndFixLoop's stack-dependent parameters are three-state
(orca.Configured), so omission means "from the project's stack
settings" while "explicitly off" stays expressible:
enum Configured[+A]:
case FromSettings // resolve from the run's stack settings (the default)
case Off // explicitly disabled for this call
case Use(value: A) // explicit value; settings ignoredFromSettings resolves formatCommands to stackSettings.format and builds
the lint gate as Lint(stackSettings.lint, reviewAgent.cheap) — commands plus
the summariser agent bundled in one value (Lint(commands: List[String], agent)). An empty list resolves to no gate at all: FromSettings over empty
settings behaves exactly like Off. A script that omits lint gets a lint gate
whenever the target project's settings define one; for format-only, pass lint = Configured.Off.
Each round runs its checks one at a time, after the format commands and
before the reviewers and the lint gate start, so a check that builds or times
the code has the machine to itself. A check must not modify sources. Keep a
finding's title the same across rounds and put measurements in its description:
the loop recognises a finding it already holds as open by its title and file.
With no reviewers, the loop just evaluates the check and fixes:
val benchmark = new ReviewCheck:
def name = "benchmark"
def evaluate()(using ctx: FlowContext, ev: InStage): ReviewResult =
val ms = os.proc("./bench.sh")
.call(cwd = ctx.workDir, stderr = os.Pipe).out.trim().toInt
if ms <= 200 then ReviewResult.empty
else ReviewResult(List(ReviewFinding(Title("Request too slow"),
s"p99 is $ms ms; the target is 200 ms", location = None,
suggestion = None, reopens = None)))
stage("Speed up"):
reviewAndFixLoop(
coderSession = session,
reviewers = Nil,
task = Task(Title("Make requests faster"), ""),
lint = Configured.Off,
checks = List(benchmark)
)The whole loop is one stage, so one commit. When each iteration is long, write
the loop in the flow instead, one stage per iteration calling
coderSession.run, so a resume picks up at the last finished iteration.
The change set reviewers are shown — and that the selector picks from — is
everything the enclosing stage has produced since it began, so it is the same
whether or not the coding agent committed its own work along the way. It is
re-sampled each round and sent to every reviewer that runs, resumed ones
included, so each round's reviewers see the fixes made before it. Pass
diff = ReviewDiff.Pinned(...) to pin it instead: reviewers are then not told a
base commit, the selector's changed-file list is scraped from the diff text, and
every later round finds the same text, so a resumed reviewer is told there is no
new change set.
diff = ReviewDiff.WholeRun widens it to everything the run has changed since
it started — since the commit HEAD pointed at when the run bound its branch,
recorded in the progress log — so a stage placed after the per-task work
reviews the whole branch, earlier stages' commits included. Reviewers are told
the change set spans every stage. A run whose log records no usable commit (a
log from before orca recorded one, or one whose commit no longer sits behind
HEAD after a rebase) has no base: the call says so in a step and returns without
reviewing.
That is the final-review half of the shape every task-based built-in flow uses
— reviewThenFix per task, then this once:
stage("Final review"):
reviewAndFixLoop(
coderSession = session,
reviewers = allReviewers(reviewAgent),
task = Task(Title("The whole planned change"), plan.brief),
diff = ReviewDiff.WholeRun,
maxFixTurns = 5
)The cap is above the library default of 3 because nothing reviews again after
this loop. Each flow hands what it returns to its PR step
(openPrIfGitHub/openPrFromBranch), which lists every finding still open in
the PR body.
A change set past 128 KiB is cut down before it is sent: the reviewer gets as many whole files as fit, then a list naming every other changed file with its line counts, and reads those files itself. Without that, the largest change sets make a request no model can accept. A pinned diff is sent as given.
reviewAndFixLoop's reviewerSelection defaults to ReviewerSelector.default,
which narrows twice: a picker LLM on reviewAgent's cheap tier chooses from the
supplied list for round one, seeing each reviewer's description and the changed
file paths; every later round then re-runs only the reviewers that reported a
finding in the previous one. A reviewer that stays quiet stops costing a turn
per round — the trade-off is that it won't see the fixes made after it stopped.
If narrowing would leave no reviewer at all (everyone quiet, while a lint finding
keeps the loop going), the round's full selection runs again and a step says so.
| Selector | Behaviour |
|---|---|
default |
The above: narrowingAcrossRounds(agentDriven). |
allEveryRound |
The whole supplied roster, every round; no picker. |
agentDriven |
Pick once with reviewAgent.cheap, replay that pick every round. |
agentDriven(agent, instructions?) |
As above with a chosen picker model and brief. |
narrowingAcrossRounds(base) |
Adds the per-round narrowing over any base. |
A reviewer declaring a files: pattern in its frontmatter (of the shipped set,
only scala-fp) is offered to the picker only when a changed file matches it —
unless nothing is known about the change set, in which case it stays eligible.
The selector reads each reviewer's name, description and pattern off its
Reviewer, so your own reviewers are described and gated the same way.
To swap or extend the reviewer set for one project, drop .md files in
.orca/reviewers/ — no code changes (see Settings). To do it from
the flow, compose your own List[Reviewer] from reviewerCatalog.all (the
run's resolved set), ReviewerPrompts (the shipped entries alone), and/or your
own Reviewer(ReviewerSlug(name), description, systemPrompt), then turn it into
ReviewerAgents with buildReviewers(base, list).
PR utilities, available via import orca.pr.*:
| Method | Use |
|---|---|
summarisePr(agent, diff, context?, instructions?) |
Fold a branch diff into a PrSummary(title, body) for gh.createPr. context is an optional preamble (originating issue link, user prompt, etc.) the model anchors the description to. A diff too large to send is cut short. Use a cheap model (claude.cheap, codingAgent.cheap). |
openPrFromBranch(summarisingAgent, openFindings, title?, body?, context?, instructions?): PrHandle |
Push the feature branch and open a PR for it, as three stages: push → summarise → create. Requires a GitHub remote and a logged-in gh — without either the run fails. openFindings is the OpenFindings the run's final review returned; each entry is listed under "Open review findings" as its title, where it points if the reviewer named a place, and the reason, verbatim, after a line saying so if the review was skipped (none open and not skipped: no section). The same section is printed to the run output, also when the PR fails. title/body rewrite the generated text. context defaults to the run's user prompt, and then the summariser adds a Closes #N line per issue the prompt says to fix; a flow that passes context adds its own Closes line through body (body = s => s"${s.body}\n\nCloses #42."). Opening the PR is a top-level step of a flow and this runs its own stages, so it does not compile inside one. |
openPrIfGitHub(summarisingAgent, openFindings, title?, body?, context?, instructions?): Option[PrHandle] |
Probes gh.availability before its push stage runs (a resume that replays the push skips it), then runs openPrFromBranch's push → summarise → create when the checkout is on GitHub. Where it isn't — no remote, a remote that isn't GitHub, a GitHub gh cannot reach, a run that changed no code, or a push/create the remote refuses — it emits one Step saying why, returns None, and the run finishes. The open findings are printed to the run output either way. A resume replays what its push and create stages recorded, a refusal included. The step every code-producing built-in flow ends with; like openPrFromBranch, it does not compile inside a stage. |
bodyWithOpenFindings(body, open) |
body with the "Open review findings" section appended, or body unchanged when nothing is open and the review ran — the assembly openPrFromBranch/openPrIfGitHub use, for a flow that writes its own PR body (gh.updatePr). |
reportOpenFindings(open) |
Print the "Open review findings" section to the run output; nothing when nothing is open and the review ran. openPrFromBranch/openPrIfGitHub do this themselves, before their PR step; a flow that writes its own PR body calls it before its PR step. |
recordOpenedPr(pr) |
Record the PR's URL as the run's published work, so the run hands the checkout back on the branch it started from and the closing summary names the PR. Only for a flow that opens its PR with a bare gh.createPr — openPrFromBranch/openPrIfGitHub record it themselves. Call it inside the stage that opened the PR (it needs that stage's WorkspaceWrite): the stage's commit carries the record, and a resume reads it back without re-running the body. |
Every domain helper that bundles an LLM brief takes its prompt as a
default-valued instructions: String; the default lives on a sibling
XxxPrompts object. Override it, or compose with the default to extend it:
import orca.plan.{Plan, PlanPrompts}
Plan.interactive.from(
userPrompt,
claude,
instructions = PlanPrompts.Planning + "\n\nPrioritise observability tasks first."
)Where the defaults live
orca.plan.PlanPrompts—Planning,AssessThenPlan,Triage,Revieworca.pr.PrPrompts—Summariseorca.review.ReviewLoopPrompts—Fix,SelectReviewers,SummariseLintorca.review.ReviewerPrompts— per-reviewer system prompts (compose your own list to swap or extendallReviewers/minimalReviewers)
The lower-level per-call wrappers (autonomous/interactive/retry) are a separate
layer — replace the whole set via flow(prompts = ...). See ADR
0010 for the full convention.
Common types you'll see in flow scripts. Most derives JsonData, making them
valid stage results (the progress log can record and replay them) and usable as
structured LLM output via claude.resultAs[T]. Exceptions: WithChat and
Verdict do not derive JsonData — they are intermediate values, not stage
results.
The types, in detail (click to expand)
orca.plan.Plan(epicId, description, tasks, brief)— the task list the agent generates in one round-trip.epicIdis a kebab-case identifier for the plan itself (heads its markdown render) — NOT the git branch name; the flow derives and announces its own branch separately (seeBranchNamingStrategy).descriptionis the planner's epic summary;briefis a concise codebase briefing always included (feed it toagent.session("implementer", seed = plan.brief), which threads it as the seed).taskPrompt(task)prepends the brief to a task's description.orca.plan.Task(title, description)—titleis the human-readable label shown in the event log.orca.plan.WithChat(chat, value)— everyPlan.{autonomous, interactive}.*operation returns one: the result paired with the (ephemeral)Chatthat produced it, so the caller can continue that conversation in-run or.valueit and start fresh. Only the library builds one; destructure it withval WithChat(chat, plan) = ....orca.plan.Verdict[A]—Verdict.Proceed(value)orVerdict.Rejection(kind, body)(kind ∈ Question / Critique / Rebuff). Returned byassessThenPlanasVerdict[Plan].orca.plan.Triage— sum type returned bytriage:NotABug,Untestable, orTestable— each carrying exactly the fields its branch needs.orca.plan.BugReportMatch— the agent's decision on whether a CI failure matches the original report.orca.FlowSession— durable, resumable session handle returned byagent.session(name, seed). Call.run(prompt)or.resultAs[O].run(input)on it to drive the agent, with automatic seed/preamble replay (when the backend conversation isn't live) and resume-wire-id persistence.session.chatis its conversation as an ephemeralChat(the fork-side escape hatch).orca.agents.Chat[B]— ephemeral multi-turn conversation handle fromagent.chat(): tool-using and workspace-editing like any agent turn ("chat" names its lifetime, not its powers), in-run only, fork-safe. Also carried byWithChatfor planning-conversation continuations.orca.Title— opaqueStringalias for short labels (Task.title,ReviewFinding.title);Title("…")to construct,.valueto read.orca.tools.PrHandle— handle to an open pull request (host,owner,repo,number), returned bygh.createPr. Build one withPrHandle.from(host, owner, repo, number)(aLeftnames the field that is not a valid host, owner, repo or PR number) orPrHandle.fromUrl(url).hostisgithub.comor a GitHub Enterprise hostname, and everyghcall taking the handle is routed to it. Has aJsonData(it travels as its URL) so a stage can record it: a push-and-open-PR stage is the checkpoint before a CI wait.IssueHandlecarries no host, so the issue flows read their issue from gh's default host (GH_HOST, else the host gh is logged in to).orca.tools.GitHubAvailability— whatgh.availabilityanswers with.Available(host, owner, repo): the repository gh resolves, on github.com or a GitHub Enterprise host.Unavailable(why): no PR can be opened;whyis aGitHubUnavailable—NoRemote(noorigin),NoHost(remote)(originhas no host, a local path),NotGitHub(host)(gh has no login for that host, so a GHES host needsgh auth login --hostname <host>),Unreachable(host, reason)(the host is GitHub, but gh could not answer for it —reasonis gh's own words), orGitUnusable(reason)(git itself could not be run, so nothing is known about the checkout).why.explanationrenders that as one line to put a flow's own next action after.orca.pr.PrSummary(title, body)— whatsummarisePrreturns. The two fields feedgh.createPr(title = …, body = …)directly.orca.review.ReviewFinding/ReviewResult— what reviewer agents return. A finding carries atitle(shown), a longdescription(sent to the fixer), an optionallocation, andreopens: theFindingIdof the still-open finding it reports again, if any.orca.review.OpenFindings(findings, skipped)— accumulatedOpenFinding(id, title, reason, location)entries surfaced byreviewAndFixLooponce it halts: every finding the run did not resolve, each with where it points and anOpenReason—Declined(text)(the fixer's own words),NoFixes,Unaccounted,CapReached(max),StillFailing(sources)orCustom(text)(fromOpenFinding.custom).reason.describeis the sentence shown to a reader.id(FindingId) is what entries merge by across rounds; two findings sharing a title stay two.skippedisSome(SkippedReview)when the review never ran.orca.StackSettings(format, lint, test)— the resolved per-project tooling commands (each field aList[String], run viabash -c; empty = gate disabled). Resolved once per attempt — see Settings — and read back viasummon[FlowContext].stackSettings; passflow(stackSettings = Some(...))to pin it.orca.Configured[A]— three-state default forreviewAndFixLoop's stack-dependent parameters:FromSettings(the default — resolve from the run's stack settings),Off(explicitly disabled for this call), orUse(value)(explicit value; settings ignored).orca.review.Lint(commands, agent)— the lint gate bundlereviewAndFixLoopruns alongside the reviewers: the shell commands plus the (cheap) agent that summarises their output into aReviewResult.
While Orca runs the terminal output is split into two zones: an event log that grows top-to-bottom as stages and tools fire, and a status line pinned to the bottom, showing the active stage breadcrumb with a spinner. Nested stages are indented.
Glyph legend
| Glyph | Meaning |
|---|---|
▶ |
Stage start, or a Step (single-line note like a branch switch) |
▸ |
The prompt sent to an agent |
● |
Assistant prose |
⏺ |
Tool call (path / command / query in grey). A read-only call shows as a bare ⏺ read, with no filename and no agent name, so a burst of them folds into one line; the trace file has both |
⎿ |
How many times the line above repeated (⎿ ×12) |
✖ |
Error |
? |
Approval request, or a question for you (interactive calls only) |
! |
Caveat about what Orca can enforce for this run (never indented under a stage) |
Colours and animation auto-disable when stderr isn't a terminal. Set
NO_COLOR=1 or ORCA_NO_ANIMATION=1 (suppresses the spinner) to force them
off.
Each CLI manages its own auth; Orca stores no secrets. Before running a flow,
log in to the backend you use — claude, codex, opencode, or pi — and to
gh (for the GitHub helpers), each per its own instructions.
OpenCode with a local Ollama model
- Launcher (zero config):
flow(OrcaArgs(args), opencode = Some(w => OpencodeAgents.default(w, OpencodeLauncher.ollama("qwen3-coder")))). Orca starts the server viaollama launch opencode, which injects Ollama's provider config and pins that one model — use bareopencode, nowithModel. Needs theollamaCLI and the model pulled. - Manual config: declare an
ollamaprovider in~/.config/opencode/opencode.json(baseURLhttp://localhost:11434/v1, your models,num_ctxraised for tool use), thenopencode.withModel("ollama", "qwen3-coder"). Supports several models and per-turn switching.
Orca is published to Maven Central — scala-cli fetches the artifacts on first
run:
scala-cli run --workspace "$(mktemp -d)" implement.sc -- "your prompt here"--workspace keeps scala-cli's build output out of your repository; without it
you get a .scala-build directory next to the script.
For a guided start, install Orca Shell instead: its first-run wizard configures the role agents and models for you.
Orca Shell is an interactive terminal front-end for the same flow scripts: a
first-run wizard picks a harness and model for each of the
planning/coding/review roles — writing the same global settings.properties
described under Settings — then a menu lets you discover flows
(project, global, and built-in), run one, view or edit its source, create a new
flow (or fork an existing one) with the configured role agents' help, or
continue a session left by a previous run. It launches flows the same way
scala-cli run does — direct scala-cli run flow.sc -- "prompt" keeps working
unchanged.
Every action in the interactive menu also has a scriptable subcommand — orca
with no arguments starts the interactive shell; orca <command> ... runs one
action non-interactively and exits.
| Command | Key flags | Does |
|---|---|---|
orca run <flow> [prompt] |
--prompt <prompt> (the prompt, for text starting with -; not with the positional), --verbose (stack trace on abort), --branch <name> (create the run's branch under this name; refused with --skip-branch), --skip-branch, --keep-changes (leave uncommitted files in place), --worktree (run in a git worktree of this repository), --honor-pin (use the flow's own pinned orca version) |
run a flow, propagating its exit code; the prompt is read from stdin when omitted and piped |
orca view <flow> |
--plain, --color |
print a flow's source (highlighted when stdout is a terminal) |
orca edit <flow> |
--to project|global |
open a flow in $VISUAL/$EDITOR/vi (--to required to customize a built-in) |
orca create "<goal>" |
--name <file>, --global |
author a new flow: the built-in simple.sc flow writes it in an isolated sandbox with the configured role agents; --name is auto-derived when omitted. The sandbox is a fresh repository with no remote, so the flow's closing PR step opens nothing and says so |
orca fork <source> "<changes>" |
--name <file>, --global |
fork an existing flow, the same way |
orca continue [selector] |
--list, --json |
resume a recorded harness session (no selector = newest); selector is an id from --list (it keeps naming the same session while other attempts record theirs), a session name, or a branch — a name matching several sessions in one working tree resumes the most recent of them; a selector matching both a name and a branch is refused |
orca config |
--planning-agent, --coding-agent, --review-agent, each taking harness[:model]; or --edit project|global |
show the configured role agents, set any subset, or hand-edit that tier's settings file in $VISUAL/$EDITOR/vi (created from its template if absent) |
orca list |
--json |
list discovered flows across the project/global/built-in tiers |
orca clear-stack |
--yes |
clear discovered stack settings so the next flow run re-detects them |
create, fork, edit, continue's resume, and config --edit each need a
real terminal and error cleanly if run without one; run, view, list,
config (without --edit), and clear-stack --yes work fine piped or in CI.
Examples:
orca run implement.sc "add a rate limiter to /login"
echo "add a rate limiter" | orca run implement.sc
orca list --json | jq -r '.[].name'
orca create "add a token-bucket limiter" --name rate-limit.sc
orca continue # resume the last session
orca continue --list
orca continue feat/rate-limiter
orca config --coding-agent codex
orca config --review-agent claude:sonnet
orca view implement.scRun orca --help for the full command list, or orca <command> --help for a
command's own flags. Exit codes: 0 success, 1 action failure, 2 usage error —
orca run propagates the flow's own exit code, which makes it CI-friendly.
Install it with:
curl -fsSL https://raw.githubusercontent.com/VirtusLab/orca/master/install.sh | bashThe script does exactly two things:
- If
scala-cliisn't on yourPATH, it downloads and runs scala-cli's official installer (which places scala-cli in its own versioned location and updates your shell profile; scala-cli then manages its own JVM). - It writes the
orcaexecutable to~/.local/bin/orca— a short launcher script that runs the latest releasedorca-shellviascala-cli. Nothing else is downloaded at install time; the artifacts are fetched on the firstorcarun, and the launcher never needs a version bump.
Add ~/.local/bin to your PATH if the installer says it isn't there yet, then
run orca.
To avoid installing anything, or to pin a version (e.g. in CI), run the shell
directly instead. The pinned form works from the first release that includes the
shell; the version below always tracks the latest release. --workspace keeps
scala-cli's own build metadata out of the current directory (it lands under the
given directory instead):
scala-cli run --workspace "${XDG_CACHE_HOME:-$HOME/.cache}/orca/shell/workspace" --jvm 21 --quiet --verbose --dep "org.virtuslab::orca-shell:0.1.10" --main-class orca.shell.Mainadr/— architecture decision records. ADR 0018 describes the current stage-bound runtime; the ADR index covers module layout, backends, the flow DSL, and reviewers.CONTRIBUTING.md— building, testing, and running a locally modified orca.AGENTS.md— internals, architecture, and coding conventions; the same file AI assistants pick up.
Apache 2.0 — see LICENSE.
Copyright (C) 2026 VirtusLab https://virtuslab.com.