Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ site rather than rewiring modules.
The user-facing surface lives in `package orca` (the `flow` entry, the tool
accessors — including `planningAgent`/`codingAgent`/`reviewAgent`, the
backend-agnostic role accessors (ADR 0020) — `stage`/`display`/`fail`,
`JsonData`, `OrcaArgs`). Implementations live in
`JsonData`, `OrcaArgs` and the `RunTarget` its flags parse into).
Implementations live in
focused subpackages: `orca.tools` (os-backed git/gh/fs impls + their traits),
`orca.agents` + `orca.backend` (LLM SPI, `SessionSupport`,
conversation driver), `orca.subprocess` (subprocess shim), `orca.sweep`
Expand Down
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,31 +325,36 @@ Each `flow(...)` run is bound to exactly one feature branch and one progress log

- **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. `--skip-branch` (`OrcaArgs.skipBranch`) binds the run to the CURRENT
header. The three flags below reach a flow as one `OrcaArgs.target`
(`RunTarget`), which has no case for a combination orca refuses.
`--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-branch` run 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`
(`OrcaArgs.keepChanges`) 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` (`OrcaArgs.worktree`) runs the whole flow in
(`Uncommitted.Keep` on 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 run and reused by
every later one for that task. It isolates the run: two tasks can run at once
without sharing a checkout or a branch. Uncommitted work does NOT come along —
a worktree is made from a commit — so `--worktree` is refused with
`--skip-branch` and with `--keep-changes`. The first run 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 `.gitignore`
will see the second checkout, and orca never removes it. The run starts on an
`orca-worktree-<hash>` branch orca also never deletes, so full cleanup is `git
worktree remove .orca/worktrees/<hash>` **and** `git branch -d
`--skip-branch` and with `--keep-changes`: `RunTarget.Worktree` carries
neither a branch mode nor an `Uncommitted`, so the pair is refused while argv
is parsed and has no representation after that. The first run 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
`.gitignore` will see the second checkout, and orca never removes it. The run
starts on an `orca-worktree-<hash>` branch orca also never deletes, so full
cleanup is `git worktree remove .orca/worktrees/<hash>` **and** `git branch -d
orca-worktree-<hash>`; a re-run of the task refuses rather than moving that
branch if it has gained commits since.
Sharp edge: kept files are unprotected until that first stage commit — a
Expand Down
6 changes: 6 additions & 0 deletions adr/0018-stage-bound-flow-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,12 @@ the wrong branch.
> `--skip-branch` (git will not check the current branch out a second time in
> a new worktree). Both refusals happen in `OrcaArgs.parse`, before setup, the
> banner, or any git call.

> **Amendment (2026-08-28).** The three flags above no longer reach a run as
> separate fields: `OrcaArgs.target` is one `RunTarget` —
> `NewBranch(Uncommitted)`, `CurrentBranch(Uncommitted)` or `Worktree`, which
> carries neither — so a refused pair has no representation past
> `OrcaArgs.parse`, which is where it is still refused.
- **R5** — On **successful** exit the progress-log file is removed in a final
commit, and a feature branch left with no changes other than the progress log is
deleted (throwaway-branch cleanup). That removal commit is also pushed, but only
Expand Down
6 changes: 6 additions & 0 deletions adr/0021-orca-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,12 @@ harness/model/yolo flag exists for either.
> (`OrcaArgs.worktreeRefusal`), so neither the wording nor the set of refused
> pairs can drift.

> **Amendment (2026-08-28).** That shared decision is now `RunTarget.from`,
> which returns the run's destination as one value instead of an optional
> refusal string. Both parsers convert their raw flags through it, so a refused
> pair cannot be carried past argv — `FlowFlags` holds a `RunTarget`, and every
> launch path takes one.

Both entry points call a shared `orca.shell.actions` package (`FlowResolution`,
`RunAction`, `ViewAction`, `EditAction`, `AuthorAction`, `SessionAction`,
`ConfigAction`, `StackAction`): each takes fully-resolved parameters and does
Expand Down
64 changes: 23 additions & 41 deletions runner/src/main/scala/orca/OrcaArgs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package orca

import mainargs.{Flag, ParserForClass, arg}

/** Parsed command-line arguments for the `orca` entry point. */
case class OrcaArgs(
/** The argv shape mainargs parses: one raw flag per `--`-spelled option,
* including the `--worktree` combinations orca refuses. [[OrcaArgs.parse]] is
* its only consumer, and turns the three run-destination flags into a
* [[RunTarget]] — so nothing beyond the parse boundary holds them separately.
*/
private[orca] case class RawArgs(
@arg(positional = true, doc = "task description")
userPrompt: String = "",
@arg(doc = "print a stack trace if the flow aborts")
Expand All @@ -20,53 +24,31 @@ case class OrcaArgs(
worktree: Flag = Flag()
)

object OrcaArgs:
given ParserForClass[OrcaArgs] = ParserForClass[OrcaArgs]

private val worktreeWithSkipBranchRefusal: String =
"--worktree cannot be combined with --skip-branch: --skip-branch runs on " +
"the branch checked out now, and git will not check that branch out a " +
"second time in a new worktree"

private val worktreeWithKeepChangesRefusal: String =
"--worktree cannot be combined with --keep-changes: --keep-changes works " +
"on uncommitted files, which stay behind in the invoking checkout — a " +
"worktree is created from a commit and starts clean"

/** Why these flags cannot be combined, or `None` when they can — asked of a
* whole `OrcaArgs`, so neither [[parse]] nor `flow()` spells out a triple of
* same-typed booleans that a transposition would silently reorder.
*/
private[orca] def worktreeRefusal(args: OrcaArgs): Option[String] =
worktreeRefusal(
worktree = args.worktree.value,
skipBranch = args.skipBranch.value,
keepChanges = args.keepChanges.value
)
/** Parsed command-line arguments for the `orca` entry point. */
case class OrcaArgs(
userPrompt: String = "",
verbose: Boolean = false,
target: RunTarget = RunTarget.NewBranch(Uncommitted.Stash)
)

/** The same question over loose booleans, for the shell — it holds
* `FlowFlags`, not an `OrcaArgs`, and refuses the pair before it spawns a
* flow at all. What must not drift is which pairs are refused, not only how
* each refusal reads.
*/
private[orca] def worktreeRefusal(
worktree: Boolean,
skipBranch: Boolean,
keepChanges: Boolean
): Option[String] =
if !worktree then None
else if skipBranch then Some(worktreeWithSkipBranchRefusal)
else if keepChanges then Some(worktreeWithKeepChangesRefusal)
else None
object OrcaArgs:
private given ParserForClass[RawArgs] = ParserForClass[RawArgs]

/** Parse the given argv or return a human-readable error — including for a
* contradictory `--worktree` pair, refused here so it fails at parse, before
* the banner and before anything touches git.
*/
def parse(args: Seq[String]): Either[String, OrcaArgs] =
summon[ParserForClass[OrcaArgs]]
summon[ParserForClass[RawArgs]]
.constructEither(args.toList)
.flatMap(parsed => worktreeRefusal(parsed).toLeft(parsed))
.flatMap: raw =>
RunTarget
.from(
worktree = raw.worktree.value,
skipBranch = raw.skipBranch.value,
keepChanges = raw.keepChanges.value
)
.map(OrcaArgs(raw.userPrompt, raw.verbose.value, _))

/** Overload for scala-cli flow scripts, whose top-level `args` is
* `Array[String]`. Throws `OrcaFlowException` on a parse failure.
Expand Down
91 changes: 91 additions & 0 deletions runner/src/main/scala/orca/RunTarget.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package orca

/** What a run does with uncommitted and untracked files it finds in the working
* tree at start (`--keep-changes` asks for [[Uncommitted.Keep]]).
*/
enum Uncommitted:
/** Stash them, so the run starts from committed content. */
case Stash

/** Leave them in place for the flow to work on and commit. */
case Keep

/** Where a run's work goes: which branch it commits onto, and — in the cases
* where the question arises at all — what happens to uncommitted files.
*
* This is what `--skip-branch`, `--keep-changes` and `--worktree` become once
* argv is parsed. `--worktree` combines with neither of the other two (a
* worktree is created from a commit, so it starts clean and checks out a
* branch of its own), so [[Worktree]] carries no `Uncommitted` and is not a
* branch mode: the refused combinations have no representation here, and
* [[RunTarget.from]] — the only way in from raw flags — is where they are
* refused.
*/
enum RunTarget:
/** A branch orca creates in the invoking checkout — the default. */
case NewBranch(uncommitted: Uncommitted)

/** The branch checked out now; the flow commits onto it (`--skip-branch`). */
case CurrentBranch(uncommitted: Uncommitted)

/** A separate checkout under `.orca/worktrees/` (`--worktree`). */
case Worktree

// Views for the two setup decisions that turn on a single axis (which branch
// to bind, what to do with a dirty tree). Derived, so no caller can set one
// without the case that implies it.
def skipBranch: Boolean = this match
case CurrentBranch(_) => true
case NewBranch(_) | Worktree => false

def keepChanges: Boolean = this match
case NewBranch(uncommitted) => uncommitted == Uncommitted.Keep
case CurrentBranch(uncommitted) => uncommitted == Uncommitted.Keep
case Worktree => false

/** The flags [[OrcaArgs]] parses back, in the order the shell appends them
* after `--` when it spawns a flow child. Rendering lives next to the parser
* so both directions of one flag spelling are in a single file.
*/
def toArgv: Seq[String] = this match
case NewBranch(Uncommitted.Stash) => Nil
case NewBranch(Uncommitted.Keep) => Seq("--keep-changes")
case CurrentBranch(Uncommitted.Stash) => Seq("--skip-branch")
case CurrentBranch(Uncommitted.Keep) =>
Seq("--skip-branch", "--keep-changes")
case Worktree => Seq("--worktree")

object RunTarget:

private val worktreeWithSkipBranchRefusal: String =
"--worktree cannot be combined with --skip-branch: --skip-branch runs on " +
"the branch checked out now, and git will not check that branch out a " +
"second time in a new worktree"

private val worktreeWithKeepChangesRefusal: String =
"--worktree cannot be combined with --keep-changes: --keep-changes works " +
"on uncommitted files, which stay behind in the invoking checkout — a " +
"worktree is created from a commit and starts clean"

/** The single conversion from the raw flags an argv parser produces: a flow's
* own argv ([[OrcaArgs.parse]]) and `orca run`'s (the shell has its own
* parser for the same three flags) both come through here, so neither the
* wording of a refusal nor the set of refused pairs can drift. A refused
* pair is a message, never a value — which is what keeps it out of every
* type below this point.
*/
def from(
worktree: Boolean,
skipBranch: Boolean,
keepChanges: Boolean
): Either[String, RunTarget] =
val uncommitted =
if keepChanges then Uncommitted.Keep else Uncommitted.Stash
if !worktree then
Right(
if skipBranch then CurrentBranch(uncommitted)
else NewBranch(uncommitted)
)
else if skipBranch then Left(worktreeWithSkipBranchRefusal)
else if keepChanges then Left(worktreeWithKeepChangesRefusal)
else Right(Worktree)
35 changes: 17 additions & 18 deletions runner/src/main/scala/orca/flow.scala
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ private enum FlowOutcome:
* reused after, and everything below it — git, the progress log, the session
* manifest — uses that directory instead. A refusal (no repository, no
* commits, something orca did not create already at the path) ends the run
* before any of that starts. `--worktree` cannot be combined with `skipBranch`
* or `keepChanges`; the pair is refused here as well as in `OrcaArgs.parse`,
* since an `OrcaArgs` built by hand never passed through the parser.
* before any of that starts. `--worktree` combines with neither
* `--skip-branch` nor `--keep-changes`, and `RunTarget` — what `args` carries
* those three flags as — has no case for either pair, so the refusal happens
* once, converting argv (`OrcaArgs.parse`).
*
* Overrides default to `None` so the runtime can build the default lazily —
* `TerminalInteraction` in particular takes the resolved `workDir`, which
Expand Down Expand Up @@ -172,18 +173,14 @@ def flow(
// Where the run happens. This settles before the directory's first consumer,
// the session manifest below; everything downstream is handed `workDir`
// explicitly, so it is the single value to change.
def resolveRunDir(): Either[String, os.Path] =
OrcaArgs
.worktreeRefusal(args)
.toLeft(())
.flatMap: _ =>
if !args.worktree.value then Right(workDir)
else
// Resolution can throw as well as refuse — a symlinked or unwritable
// `.orca`, a git that won't start. One `Left` shape for every outcome
// keeps the reporting below the only way out.
try WorktreeRun.resolve(workDir, args.userPrompt)
catch case NonFatal(e) => Left(TextUtil.throwableMessage(e))
def resolveRunDir(): Either[String, os.Path] = args.target match
case RunTarget.NewBranch(_) | RunTarget.CurrentBranch(_) => Right(workDir)
case RunTarget.Worktree =>
// Resolution can throw as well as refuse — a symlinked or unwritable
// `.orca`, a git that won't start. One `Left` shape for every outcome
// keeps the reporting below the only way out.
try WorktreeRun.resolve(workDir, args.userPrompt)
catch case NonFatal(e) => Left(TextUtil.throwableMessage(e))

// The run proper. Everything under here uses `dir`, never `workDir`.
def runIn(dir: os.Path): FlowOutcome =
Expand Down Expand Up @@ -262,8 +259,10 @@ def flow(
System.err.println(s"[orca] $message")
FlowOutcome.Failed
case Right(dir) =>
val where =
if args.worktree.value then s"$dir (worktree)" else dir.toString
val where = args.target match
case RunTarget.Worktree => s"$dir (worktree)"
case RunTarget.NewBranch(_) | RunTarget.CurrentBranch(_) =>
dir.toString
flowLog.info(
"orca {} starting (workDir={})",
OrcaBanner.version,
Expand Down Expand Up @@ -315,7 +314,7 @@ private[orca] def runFlow(
wiring: FlowWiring = FlowWiring(),
pricing: PriceList = Pricing.default
)(body: FlowControl ?=> Unit): Unit =
val debug = OrcaDebug.enabled || args.verbose.value
val debug = OrcaDebug.enabled || args.verbose
// Acquire both guards before `supervised:` (neither needs an `Ox` scope) so a
// violation is caught before any git mutation. See [[FlowLock]] for the
// two-layer rationale and release-ordering symmetry.
Expand Down
18 changes: 9 additions & 9 deletions runner/src/main/scala/orca/runner/FlowLifecycle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -524,8 +524,8 @@ object FlowLifecycle:
val dirtyCount = git.dirtyPaths().size
val facts = DirtyTreeFacts(
ownLogPresent = ownLog != ProgressStore.LoadResult.Absent,
skipBranch = args.skipBranch.value,
keepChanges = args.keepChanges.value,
skipBranch = args.target.skipBranch,
keepChanges = args.target.keepChanges,
dirtyCount = dirtyCount
)
DirtyTreePolicy.decide(facts, tty, ask) match
Expand All @@ -548,7 +548,7 @@ object FlowLifecycle:
WorkspaceWrite
): UntrackedFiles =
if dirtyCount > 0 then
if args.keepChanges.value then
if args.target.keepChanges then
emit(
OrcaEvent.Step(
"ignoring --keep-changes: this task already has a progress " +
Expand Down Expand Up @@ -614,8 +614,8 @@ object FlowLifecycle:

/** Shared by [[bindBranch]]'s corrupt-log and absent-log arms: resolve +
* create a fresh branch via [[freshRun]], then mint [[BranchMode]] from
* `args.skipBranch` (skip-branch mode never creates a branch, so it reads
* as `Reused`).
* `args.target` (skip-branch mode never creates a branch, so it reads as
* `Reused`).
*/
private def freshBinding(
startBranch: String,
Expand Down Expand Up @@ -644,7 +644,7 @@ object FlowLifecycle:
BranchBinding(
branch,
startBranch,
if args.skipBranch.value then BranchMode.Reused
if args.target.skipBranch then BranchMode.Reused
else BranchMode.Created,
headAtBinding
)
Expand Down Expand Up @@ -913,7 +913,7 @@ object FlowLifecycle:
* "main" this time. [[createFreshBranch]] applies the same policy to a
* git-level collision.
*
* `args.skipBranch` skips all of this: the run binds to `startBranch`
* A `CurrentBranch` target skips all of this: the run binds to `startBranch`
* verbatim via [[reuseCurrentBranch]] instead.
*/
private def freshRun(
Expand All @@ -931,7 +931,7 @@ object FlowLifecycle:
emit: OrcaEvent => Unit
)(using InStage, WorkspaceWrite): FeatureBranch =
val branch =
if args.skipBranch.value then
if args.target.skipBranch then
reuseCurrentBranch(startBranch, protectedBranches)
else
val strategy =
Expand Down Expand Up @@ -970,7 +970,7 @@ object FlowLifecycle:
branch = branch.value,
promptHash = ProgressStore.hashPrompt(args.userPrompt),
branchMode =
if args.skipBranch.value then BranchMode.Reused
if args.target.skipBranch then BranchMode.Reused
else BranchMode.Created,
userPrompt = Some(args.userPrompt),
flowName = flowName,
Expand Down
Loading
Loading