diff --git a/examples/implement-enhanced.sc b/examples/implement-enhanced.sc index a20c343..126ad8d 100644 --- a/examples/implement-enhanced.sc +++ b/examples/implement-enhanced.sc @@ -1,13 +1,13 @@ //> using dep "org.virtuslab::orca:0.0.11" //> using jvm 21 -/** Persistent planning + coding flow, enhanced with a plan review and a shared - * codebase brief. +/** Persistent planning + coding flow that lands the work on its own branch and + * opens a pull request. * * Same backbone as `implement.sc` (autonomous planning → persistent - * `.orca/plan-.md` → per-task implement + review-and-fix loop), with two - * steps chained onto planning — both on the planner's read-only session, so - * they cost no extra exploration: + * `.orca/plan-.md` → per-task implement + review-and-fix loop), enhanced + * the same way as before — both on the planner's read-only session, so they + * cost no extra exploration: * * 1. **`.reviewed(claude)`** — the planner critiques its own draft and * returns an improved plan (missing/duplicated tasks, ordering, vague @@ -17,24 +17,67 @@ * `plan.taskPrompt(task)` prepends it to every task so the cold-starting * coding agents don't re-discover what the planner already learned. * - * The brief rides in the plan file (a trailing `## Brief` section), so - * `recoverOrCreate` / `implementTaskLoop` persist, reuse, and remove it with - * the file — no sidecar. + * On top of that, like `issue-pr-bugfix.sc` but prompt-driven and with no + * failing-test/CI stages, the flow runs on a fresh branch and finishes with a + * PR: * - * Swap to `.briefed(claude).reviewed(claude)` to also review the brief; both - * are well-typed. + * 1. On a fresh run it stashes any WIP, has haiku name a branch from the + * prompt, and switches to it. + * 1. Plans + implements all tasks (each committed on the branch). + * 1. Pushes and opens a PR with a haiku-generated title + description from + * the full branch diff. A human picks the PR up from there. + * + * Resume: an interrupted run leaves you on the orca branch with the committed + * plan file, so re-running continues the loop in place (the `recover` guard + * skips the stash/name/checkout). A resumed run ends on the orca branch — it + * can't recover the original starting branch; a fresh run returns to where it + * started. Re-running a finished flow is a no-op: `createPr` reports + * `PrAlreadyExists`, which the flow tolerates. * * ```bash * scala-cli run implement-enhanced.sc -- "Add a multiply function to the calculator crate" * ``` * - * Requires `claude` logged in and `cargo` on PATH. + * Requires `claude` logged in, `cargo` on PATH, and `gh` authenticated. */ import orca.{*, given} +// Not in the `orca.*` export wildcard; imported by name to tolerate a re-run +// against a branch whose PR a prior run already opened. +import orca.tools.PrAlreadyExists + +/** Structured branch-name suggestion — a single kebab-case slug from haiku. */ +case class BranchName(name: String) derives JsonData flow(OrcaArgs(args)): val planFile = Plan.defaultPath(userPrompt) + // Captured before any branch switch so the flow can return here at the end. + val startBranch = git.currentBranch() + + // Fresh run only: stash WIP, name a branch, switch to it. On resume the plan + // file is already committed on the orca branch we're sitting on, so `recover` + // short-circuits this block and the loop below continues in place — naming a + // branch here would otherwise create a second one (haiku isn't deterministic). + if Plan.recover(planFile).isEmpty then + // Stash pre-existing edits before switching branches, or they'd ride onto + // the orca branch. `ensureClean` emits a Step the user can act on + // (`git stash pop`) once the flow finishes. + val _ = git.ensureClean("orca: pre-implement stash") + val branchName = stage("Name a branch"): + claude.haiku + .resultAs[BranchName] + .autonomous + .run( + s"""Suggest a git branch name for the task below. Use a short, + |kebab-case slug prefixed with `orca/` (e.g. + |`orca/add-multiply-fn`). Output the name only. + | + |Task: $userPrompt""".stripMargin + ) + ._2 + .name + stage(s"Create branch $branchName"): + git.checkoutOrCreate(branchName) // Plan → review → brief, all on one read-only planner session. On resume the // persisted plan (with its brief) is reused without re-planning. @@ -65,3 +108,23 @@ flow(OrcaArgs(args)): lintCommand = Some("cargo check --tests"), lintLlm = Some(claude.haiku) ) + + // The task loop has removed the plan file and committed that removal, so the + // branch now holds only the implementation. Push it and open the PR from the + // full branch diff. + stage("Push branch"): + git.push().orThrow + + val prSum = stage("Generate PR title and description"): + summarisePr(llm = claude.haiku, diff = git.diffVsBase(git.defaultBase())) + + stage("Open PR"): + gh.createPr(title = prSum.title, body = prSum.body) match + case Left(_: PrAlreadyExists) => () // a prior run already opened it + case Left(e) => throw e + case Right(_) => () // the tool logs the URL + + // Return to the start branch so any stashed WIP pops back onto it. On a + // resumed run this is the orca branch itself, so it's a no-op. + stage(s"Return to $startBranch"): + git.checkout(startBranch).orThrow diff --git a/runner/src/test/scala/flowtests/FlowCompilesTest.scala b/runner/src/test/scala/flowtests/FlowCompilesTest.scala index 6a0931e..5abc4a5 100644 --- a/runner/src/test/scala/flowtests/FlowCompilesTest.scala +++ b/runner/src/test/scala/flowtests/FlowCompilesTest.scala @@ -16,9 +16,14 @@ package flowtests // regressed. Fix the API, not the test. import orca.{*, given} +// Deliberately NOT in the `orca.*` export wildcard: a recoverable `createPr` +// failure, referenced by name in `examples/implement-enhanced.sc`. Pinning it +// here keeps the "import it explicitly" requirement honest. +import orca.tools.PrAlreadyExists case class PlanTask(branchName: String, description: String) derives JsonData case class FlowPlan(tasks: List[PlanTask]) derives JsonData +case class BranchSlug(name: String) derives JsonData object FlowCanary: @@ -134,6 +139,34 @@ object FlowCanary: gh.writeComment(pr, "pr comment") gh.updatePr(pr, "new title", "new body") + /** Branch + PR surface — exercised by `examples/implement-enhanced.sc`. Pins + * the resumable-branch ops (`recover` guard, `ensureClean`, + * `checkoutOrCreate`, `currentBranch`, `checkout`), the `createPr` `Either` + * with its recoverable `PrAlreadyExists`, and the merge-base diff that feeds + * `summarisePr`. A signature drift surfaces here instead of at the next live + * run. + */ + def branchAndPrSurface(): Unit = + flow(OrcaArgs()): + val planFile = Plan.defaultPath(userPrompt) + val startBranch = git.currentBranch() + if Plan.recover(planFile).isEmpty then + val _ = git.ensureClean("orca: pre-implement stash") + val branch = + claude.haiku.resultAs[BranchSlug].autonomous.run(userPrompt)._2.name + git.checkoutOrCreate(branch) + stage("pr"): + git.push().orThrow + val summary = summarisePr( + llm = claude.haiku, + diff = git.diffVsBase(git.defaultBase()) + ) + gh.createPr(title = summary.title, body = summary.body) match + case Left(_: PrAlreadyExists) => () + case Left(e) => throw e + case Right(_) => () + git.checkout(startBranch).orThrow + /** Planning grid surface; exercised across `examples/`. Pins the full `mode × * operation` grid: every cell returns `Sessioned[B, ]` where the * result is `Plan` (`from`), `Verdict[Plan]` (`assessThenPlan`), or `Triage` diff --git a/runner/src/test/scala/orca/runner/terminal/TerminalEventListenerTest.scala b/runner/src/test/scala/orca/runner/terminal/TerminalEventListenerTest.scala index ada1ec5..f1d2959 100644 --- a/runner/src/test/scala/orca/runner/terminal/TerminalEventListenerTest.scala +++ b/runner/src/test/scala/orca/runner/terminal/TerminalEventListenerTest.scala @@ -23,7 +23,8 @@ class TerminalEventListenerTest extends munit.FunSuite: val ps = new PrintStream(buf) val output = new TerminalOutputState(ps, useColor = false, animated = animated) - val listener = new TerminalEventListener(output, useColor = listenerUseColor) + val listener = + new TerminalEventListener(output, useColor = listenerUseColor) events.foreach(listener.onEvent) buf.toString