Shipping and review: from change to merged PR
How a change becomes a merged pull request: the independent review loop, PR-state classification, CI and conflict remediation, and the merge path's rules.
Last updated
The commands
orgabot ship [project] # open the draft PR and run review → fix → re-review → merge
orgabot ship <project> --branch <branch> # ship exactly that pending branch
orgabot ship [project] --all # ship every actionable pending branch, with per-item results
orgabot review <project> <pr#> [--post] # one independent review
orgabot review deep-scan <project> # every configured scanner, whole-repo, once (adds to, never replaces, the per-tip gate)
orgabot review-loop <project> <pr#> [--max N] [--edit-only | --shell] [--no-merge]
orgabot merge <project> <pr#> [--method squash|merge|rebase]
orgabot ci-remediate <project> <pr#> # iterate on failing CI, feeding failures back to a worker
orgabot conflict-remediate <project> <pr#> # auto-resolve merge conflicts when it is safe toorgabot ship also takes --no-review, --no-ci, --no-merge, and --review-max N.
orgabot ship <project>with no--branchpicks the most recent pending mission for that project. When several missions are pending on one project, aim the ship with--branch <branch>(the branches are listed byorgabot pending list), or ship them all with--all.
A pending record is work that is built but not yet delivered, so a branch whose pull request is already open has none: an open-PR resumption (orgabot pr-recovery resume), or any mission whose first ship opened its PR. orgabot ship --branch <branch> on such a branch still refuses with no pending record for branch ..., and now names the mission that owns the branch and the commands that act on it: orgabot review-loop <project> <pr#> to continue the pull request's review and merge, or orgabot follow-up <mission-id> "<instruction>" to steer the mission. The pull request it names is the one the mission recorded; the message does not read its live state, so check that it is still open before running review-loop.
orgabot ship --all ships every actionable pending record, sequentially, and prints a per-item result table at the end: one failing branch never drops the rest of the batch, and the exit code is non-zero if any item failed. "Actionable" is judged from observed branch and PR facts (a record whose PR merged, or whose branch is gone, is never shipped), never from what the record claims about itself.
Before it ships, orgabot ship recovers recent missions whose branch is committed ahead of the base with no pull request and no pending record, and turns each into a pending record so it can be delivered. A branch whose changes the base already has (a squash-merged or cherry-picked branch still carries its original commits, so it looks "ahead") is not recovered, and a pending record in that state is retired at ship time with "Nothing to deliver" instead of opening an empty pull request. orgabot ship --dry-run writes nothing: it prints the branches it would recover (would recover shippable branch ...) and what it would ship, and leaves no record behind for a later ship to deliver.
A pull request title is cut from the mission's typed objective. When a record has none, the title skips the context Orgabot injects ahead of the ask (the role brief, organization memory, fix-and-ship boilerplate) and never starts with a markdown heading.
The same actions exist on the dashboard's Pull Requests tab: every "built · unshipped" card shows its evidence (commits ahead of base, files changed, when it was built, and why its mission stopped) and carries a one-click Ship button plus a select checkbox, with a Ship selected bulk action above the list. Every route, CLI or dashboard, goes through the exact same governed delivery path: draft PR, CI gate, independent review loop, and the ordinary merge gates. There is no raw-push shortcut.
Every newly opened PR is a draft, including role-owned epic children whose local verification and review have already passed. The shared merge path promotes it only when its merge gates allow delivery.
If GitHub rate-limits a review-evidence read, the loop retries with backoff and honours any reset time or retry-after interval GitHub supplies. When PR metadata is still readable, an unavailable diff endpoint can use a local merge-base diff pinned to the PR's reported head. An unreadable identity or a mismatched head cannot pass review. If the wall-clock evidence budget expires, the operator chooses retry or cancel. Retry starts another evidence-collection budget; it cannot authorize a merge without review.
A mission that settles into a wait records its reason in status and prints a hold line in its log. An approving review does not resolve a merge conflict: the loop brings the branch up to date, verifies it, and reviews the changed tip before merging. A review-only run explicitly records that automatic merging was disabled.
Before a durability push, Orgabot screens every outgoing commit. If another mission advanced the destination's base, the screen fetches that advertised base object before calculating outgoing history. A failed base read or fetch is reported as such; an oversized screen reports its commit range and byte limit and refuses truncated evidence.
The review is independent
The reviewer is never the agent that produced the change. It reads the diff, applies the project's review policy, and emits findings; the fix round is a real worker round that re-verifies and is then re-reviewed. Convergence, not a single pass, is the goal.
A fix round is never its own mission. It is one round of the mission that owns the pull request. Both orgabot ship and orgabot review-loop resolve that mission before the first round runs (by the PR's URL on a mission record, then by the PR's head branch, then by a pending record's mission id) and drive every fix round on its journal, so orgabot timeline for that mission shows every round rather than a scattering of unrelated cards. Each round first records the review's rejection on that journal, with the findings attached as untrusted feedback, so orgabot timeline --compliance lists a rejected independent review ahead of every fix round. This holds whichever path runs the round: the mission's own post-delivery review, orgabot ship, or orgabot review-loop. When nothing already owns the pull request (a hand-made PR, or a branch no Orgabot mission created), the loop creates exactly one mission record up front for the whole loop, rather than one per round. That record is completed, with the merge as its delivery, once the loop merges, and held once with the loop's own reason if it does not; it is never left showing as running after the loop process exits.
A loop started from a terminal shows up on the dashboard too. orgabot ship and orgabot review-loop attribute the terminal process's own pid to the mission record they drive and beat its heartbeat for as long as the loop runs, so the record's card, taskbar chip, and live log work exactly as they do for a mission the dashboard itself launched; the command prints the record's dashboard URL when it starts. Stop it the same way you stop any mission: orgabot stop <mission id> from another terminal, or the dashboard's Stop action, and the loop honours it at the next round boundary, leaving the PR exactly as it stands. A stopped mission takes no further outward action. The ship path re-reads the stop immediately before every gated step (the next review round, a handback to the fixing worker, the conflict resolver, a push, and each merge attempt), so a stop that lands while a review or the pre-merge test suite is running lets that stage finish and then holds; nothing is pushed or merged after it. The acknowledgement says so rather than implying the mission already stopped: orgabot stop prints, for example, pre-merge verification runs to completion; stop takes effect before the next gated step: merge. A mission whose record or lifecycle journal reads cancelled cannot merge even if nothing else asked it to stop. Only one driver is ever allowed on a pull request at a time: a CLI loop claims it for as long as it runs, the same claim the dashboard's automatic open-PR resumption takes, so a second orgabot review-loop on the same PR, or an automatic resumption racing a live CLI loop, refuses instead of both pushing fix rounds onto the same branch.
Two limits are worth planning around:
- The review has a diff budget. A very large diff exceeds it, and a review that cannot cover the whole change will not be treated as full coverage. The merge path refuses to merge an approval that carries a coverage gap, and
--allow-incomplete-coverageis the only way to opt into merging on partial evidence. That refusal is not a dead end: the loop spends its own rounds re-reviewing the files nothing has read yet, as coverage slices, and clears the whole-PR gate once every changed file has provably been read, uncut, by an approving slice at the current head. A slice that finds something routes into the ordinary fix round like any other finding, and any fix moves the head, which discards the coverage evidence and starts it again. A slice that reads nothing new is not abandoned: the next slice asks for half as many files, narrowing all the way down to one file at a time. Only when a single changed file is larger than the reviewer can read at all does Orgabot hold the PR and post a concrete split proposal naming the file groups to move into their own pull requests.--review-max Nis a notice, not a stop: past that many rounds the loop raises a long-run notice and keeps working toward an approval, and only an explicitorgabot stopends it on round count. So a large stack of work may still need to be split into several PRs, but Orgabot reads what it can first and tells you exactly what is left. - Generated files are reviewed through their generator. A file your repository declares generated in
.gitattributes(docs/design/gallery.html linguist-generated=true, the same attribute GitHub uses to collapse generated files) is listed to the reviewer as generated, leaves the diff budget first, and is not part of the coverage requirement. The generator source that produces it stays in the diff and must be read. Declare a file generated only when a test in your verify command pins it byte for byte to its generator, because that test, not the reviewer, is what proves it correct. Orgabot reads the declaration from your default branch, never from the pull request or the working tree, so a pull request cannot exempt its own files by editing.gitattributes. Separately, a coverage slice never re-selects a file it has already shown is too large to read in one pass: it moves on to the files still unread. - One approving round is not proof. Independent rounds have caught real defects immediately after an in-loop approval. Orgabot's own reviews are not GitHub review objects, so read the mission log rather than the PR's review count.
Every round says what the reviewer was handed, and how long it took
The verdict line carries the reviewer's own wall clock alongside its cost, and the line under it names the rung that answered, how many files it read, and how large a prompt it was given:
✓ APPROVE . 3m12s . $0.41
reviewer Codex · GPT-6 Astra · medium effort read 18 file(s) in a 141,203-char promptThe elapsed time on the phase header above a round is a different figure: by design it reports how long the previous phase took, so it says nothing about the review printed under it. The verdict line is the one to read.
When an approval comes back faster than its diff could plausibly have been read, the round prints a notice saying so. That notice is advisory: it does not change the verdict, block the merge, or add a gate. It exists so a review that did not really happen is visible in the round it happened in, rather than only as a pattern across many merged pull requests.
Every blocking finding carries a machine-readable location
Every [BLOCKING] and [IMPORTANT] finding the reviewer raises must end with one machine-readable location line, a fenced single-line JSON object naming where the vulnerability itself lives:
{"path":"src/routes.ts","line":42,"introducedByDiff":true}path is repository-relative, line is optional, and introducedByDiff is required: true when this diff is what makes the location newly reachable or exploitable, false when the location is unchanged by this diff and pre-existing. A file the finding only references, such as a suggested fix or a credential source, is not a location and must never appear in this line.
The loop scopes a finding out of a later round's merge decision only from that declaration: a finding located outside the diff's changed files with introducedByDiff: false is scoped out, and everything else, including a finding with no valid location line at all, still blocks. This replaced a run of English-prose heuristics that kept finding new phrasing they misread; a finding with no structured location is UNSCOPED and fails closed rather than being parsed.
A rejecting round's findings are never discarded
Every round's review findings, whether or not that round blocked, land on the mission's own journal as part of its ReviewFinished record, so an operator reading the mission never sees a bare "N blocking finding(s)" count: the entry carries each finding's declared location and the opening line of its own text, read straight from the same structured [BLOCKING] findings the location guard below decides on. This is written before the loop ever consults its own round budget, so a round that runs past --review-max still leaves a readable record of what it found - the budget is a notice on the mission, not a reason the findings go unrecorded, and it never leaves an operator asked to act on a REQUEST_CHANGES nobody can read.
Reviewer non-determinism is resolved by re-scoping, never a refusal
A [BLOCKING] verdict is a claim about one specific tree: this tip is not mergeable. When a later round approves that exact same tip with no commit in between, the loop does not treat that contradiction as proof of a flaky reviewer and refuse to merge outright. It re-derives the earlier round's blocking findings scoped to the diff, using the same structured location declarations described above (never the reviewer's prose), and a scanner finding is scoped the same way its own verdict-forcing decision already was: a blocking scanner whose reported paths all fall outside the diff's changed files never counted toward the verdict in the first place, and it does not count toward the contradiction either.
- If every one of the earlier round's blocking findings turns out to be outside the diff (or a scanner finding that never touched it), the earlier round was simply wrong about this tip, and the later approval stands. This is what closed out PR #2114 and PR #2095: a scanner flagged mutable GitHub Actions tags and Terraform outside the diff, forced
REQUEST_CHANGES, and the very next round's scanner correctly reported nothing in scope. - If at least one finding still touches the diff, the approval does not merge. The loop runs a fix round against those specific unaddressed findings and re-reviews, instead of holding the mission for a human to adjudicate.
Either way nothing is ever silently discarded: the loop's own log states which findings it re-derived and why the tip was or was not still blocked.
Round evidence, even when nothing was committed
A fix round that investigates a finding and lands no commit - because the finding was a flake, was already fixed, or was out of scope - still needs to leave the journal in a state where the mission can complete. cli/realMission.ts records its own VerificationFinished / ReviewFinished evidence when a round's worker produces a change, but a no-commit round never reaches that code, because there is nothing of the worker's own to verify or review.
Before this was addressed, a merged mission whose LAST round produced no commit - verified and approved by the loop itself just before merging - could settle as held under governed_evidence_pending: the completion check reads the current round's own verification and review, found neither, and refused to complete a mission that had, in fact, shipped. So the review loop now journals its own pre-merge verification (the observed pass/fail from the gate that runs immediately before merge) and its own independent review approval onto that same round, through the same gate-decision path every other verify and review event goes through, before the merge that makes them permanent. A round that already carries its own evidence from cli/realMission.ts is left untouched.
A refused fix, a wrong tree, or a run of empty rounds is loop work
The loop does not give up on its own conditions. Three of them used to be counted as strikes, and three strikes parked the mission ("N consecutive review-remediation round(s) landed no commit ... stopping") with nothing for a human to actually decide. That stop is gone:
- A fix round whose own gates refuse the fix runs its own fix and re-review cycle. Every fix round runs its own verification and independent review before it may push. When those refuse the fix, nothing reached the pull request, so the outer reviewer would only re-read the same tip. Instead the loop re-runs the fix after a backoff (30 seconds, doubling), handing it the outer findings (still unaddressed) plus the inner review's or verification's own rejection, labelled as untrusted data. A fix whose work nobody could judge (its worker died, or the host slept through verification) is retried the same way. It repeats until the fix is delivered, an operator stops the mission, or the wall-clock budget below runs out. A refused fix is never reported back as a spent round. Only those two kinds are re-run: a fix round held on anything else (waiting for your input, a dependency outage, a workflow mismatch, a release not yet delivered) is a decision the loop does not own, so the loop stops and reports that hold as it is.
- A reviewer that could not review the PR tip is re-run on the tip. The reviewer's working directory is usually a checkout of the default branch, and that is normal: the diff it is given is authoritative. It counts as a limitation only when the reviewer says it could not read or test the change because of it (the PR #2472 review said its checkout "contains
main, not this PR tip" and that "PR tests were not run"), or emits the structuredREVIEW_LIMITATION: NOT_PR_TIPline on its own, outside any code block. Then the review's APPROVE is discarded, the round is not counted, and after a backoff the review re-runs inside an isolated checkout of the PR head, with the PR's own build and tests run on that checkout first. Any blocking findings that review raised are kept: they are posted, and an approval of the same tip must still clear them through the contradiction check above, which runs a fix round when they still touch the diff. From then on every round of that loop reviews on a tip checkout. A checkout that cannot be made is retried with backoff. Both retries are bounded by the same wall-clock budget as the fix rounds. - Rounds that land no commit are a notice. After three in a row the loop raises a long-run notice (visible wherever
--timeoutnotices are) and keeps going, so the originating executor can degrade to its next configured provider. An operator may still end it withorgabot stop.
What does bound the pursuit is a wall-clock remediation budget (six hours from the first fix round). If the loop cannot produce an independently approved fix within it, it raises a pending input that names the outstanding findings and offers exactly two answers:
orgabot answer <id> merge # merge as is at the head the findings were raised against
orgabot answer <id> cancel # end the mission and leave the pull request openThat is a decision a human owns, so it is the one hold this path takes. Only an operator can answer it: an answer from inside an Orgabot worker session is refused and the request stays pending, and the request's id is kept out of the hold text and pull request comments that later fix rounds read. That refusal reads the answering process's environment for the markers Orgabot sets in every worker process, so it stops the ordinary path a worker would take; it is not proof that a person answered. Worker isolation is what keeps a worker away from the dashboard and the operator's shell.
Answering merge re-runs the review loop on the same pull request. The loop re-reads the request and checks that it belongs to this project, pull request, and mission, and was answered merge. The approval covers exactly one commit, the head the findings were raised against. The merge never moves that head: if the pull request has a newer head, is behind its base, has conflicts, fails CI, or fails the pre-merge verification, nothing merges. Failing CI or a failing pre-merge verification on the approved head is loop work: the loop runs a fix round for that failure (not for the findings you accepted), and the repaired head goes through the ordinary independent review. Your decision does not carry to that new head. In every other case the ordinary loop takes over on the current head, and either way it asks you again about the current head if that head still needs a fix for review findings. Immediately before the merge call, once every other check admits the merge, the operator's decision is recorded through the gate model as an approved human_approval decision naming the pull request and the head. The decider is the local account that answered (os-user:<name>, the account the CLI or the dashboard runs as), with the surface it came through (user for the CLI, dashboard) recorded beside it. When the account cannot be read, the surface name stands as the decider. If that merge then does not happen (GitHub refuses it, or you stop the mission first), the approval record is voided, so it never stands for a merge that did not land. The outstanding findings are posted on the pull request, and the merge goes through the shared path pinned with --match-head-commit, under the mission's own merge authority. The independent review is never recorded as having approved: the mission's summary says it merged as is on an operator decision. Any other answer is refused and the request stays pending.
What the PR description says
Orgabot writes the description; you do not have to. It is deliberately short and always the same shape:
Mission: [mission-abc](http://127.0.0.1:4317/#missions/mission-abc)
Summary: <the first sentence of what the worker reports it did>
## Problem
<what you (or the issue reporter) actually asked for>
## Solution
<what the worker changed>
## Additional Context (only when there is a correlation id)
Correlation: [corr-abc](http://127.0.0.1:4317/#work/correlations/corr-abc)
Closes #123 (one line per issue the mission is linked to)A typed instruction that names an issue is linked too. A mission does not have to be launched from the Issues tab to close one: typing fix #1884 or fix issues 1884 and 1885 into the dashboard terminal or orgabot mission links the mission to every issue number it names (#1884, issue 1884, GH-1884, owner/repo#1884, or a full issue URL all count), the same way a structured issue-driven launch does. Each linked issue gets its own line in the footer, decided independently by the same live-facts rule below: one mission can close several issues, or close some and only reference others. A follow-up on a linked mission keeps the linkage, and a follow-up that names further issues adds them. A number that turns out to be a pull request or an already-closed issue is left off the footer rather than linked on a guess. So is a number GitHub says does not exist in this repository (a 404): it gets no footer line and no "why this did not close" comment, and it never holds an otherwise complete delivery. Only a failure that says nothing about the issue (a network error, a 5xx, a rate limit) still falls back to Refs. A number another registered project's name qualifies, such as orgabot #2559 or orgabot PR #2559 in a premail mission, is that project's number and is not linked here at all; write owner/repo#N when you do mean to link an issue in another repository.
Linkage is a property of the work, not of how the mission was launched. A delivery-only relaunch (--base on an existing branch), a resume under the same mission id, and orgabot ship <branch> often carry no typed instruction naming an issue at all, but the branch does: Orgabot's own generated branch names embed fix-issue-<n>, so the branch is read for a linked issue whenever the record and the instruction have nothing to say. A retry or a follow-up also inherits its immediate parent mission's linked issues, so a lineage of relaunches keeps the same linkage the original launch established.
Delivery is a property of the branch too. A round on a branch that already has an open pull request delivers by pushing onto that pull request, through the same verify, review, and push gates a follow-up uses, however the round was started: a direct follow-up, a launch released by orgabot throttle resume-due, a resume after a dependency outage (orgabot mission resume), or an epic relaunch. The round reads the branch's pull request live once its checkout is ready and logs <branch> already has an open pull request <url>; this round delivers onto it. Before, a relaunched round could not tell, took the first-ship path, and held with Nothing to ship. No pending mission for branch <branch> while its verified, approved commit sat unpushed.
A relaunched round that makes no new edits, on a branch whose open pull request already holds everything, has nothing to push. It no longer parks with orgabot review-loop <project> <pr#> for you to run. It drives that pull request's review loop itself, the same loop a pushed round runs: the role-bound merge, the CI-green gate, and independent review on the exact tip. It settles from the loop's outcome. It is completed only when the loop merged, and it holds, naming the pull request, while the pull request is still open. An orgabot follow-up round that makes no new edits does the same: it reports "no changes" to the follow-up and then drives the pull request's review loop. So a mission held on "CI not green" no longer keeps that stale verdict after its CI turns green: the follow-up's loop re-reads live CI on the exact head through its CI-green gate, continues to review and merge when it is green, and holds with the current reason when it is still red. A fix round that a review loop started on your behalf still only reports "no changes" to that loop, which owns the review and merge.
`Closes` requires acceptance MET and an independently confirmed delivery, not just a clean diff. Passing verification and review answers "is this diff sound," not "does it cover what the issue asked for." A mission can pass every gate having delivered three of an issue's seventeen checklist items. So before Orgabot ever writes Closes #N, it measures the issue's own acceptance criteria (its task list, or the numbered items under a "Required behavior" heading) against what the mission actually delivered. The items under a "Required behavior" or "Acceptance criteria" heading run to the next heading. A Jira item is read the same way: its description's headings, bullet lists, and numbered lists keep their structure, so the bullets under a Jira "Acceptance criteria" heading are acceptance criteria, just as they are in a GitHub issue, and an "Out of scope" or "Children" list under a later heading is not. A Jira section label written as bold text rather than as a heading does not end the section, so use real headings. Any unmet item downgrades the footer to Refs #N, and Orgabot leaves a comment on the issue naming exactly which items were delivered and which were not, through the mission's own governed GitHub identity; a role-owned mission with no configured connector holds that comment rather than falling back to a personal identity. A PR summary that reads like a partial delivery ("kicked off," "will report back," "the remaining," "out of scope," "not done," or "follow-up") forces Refs regardless of how complete the checklist otherwise looks: those are the worker's own words admitting the gap. This is the same acceptance evaluation that decides whether the mission itself is allowed to terminate completed when the worker's own report declares deferred scope: one object decides both, so the mission's hold state and the PR's footer can never disagree.
Some issues are never auto-closed by a single PR. An issue labelled epic, design, or P0, or titled starting with Epic:, Design:, or Standing rule, always gets Refs #N, no matter how complete this one delivery is: those shapes are bigger than any one PR by construction, and a "P0 umbrella" issue closed off one slice's PR is exactly the failure that produced ten more duplicate incident issues in the days that followed.
An epic is referenced, not closed. Closes #N makes GitHub close the issue the moment the PR merges, which is right for a one-shot bug and wrong for an epic that sequences children: merging one slice would close the whole program while most of it is still open. So Orgabot decides the keyword from live GitHub state at the moment it writes it. It reads the issue's children from GitHub's native sub-issues and, when there are none registered, from the child references in the issue's own body (the phase table or checklist an epic usually is). If any child is still open, the footer is Refs #N instead: the PR still links to the epic, and merging leaves it open. If the children cannot be read at all (an API error, a rate limit, an unparseable body), you also get Refs, never Closes: an epic left open costs you one click, while an epic wrongly closed silently discards the rest of its scope. An ordinary issue with no children, or one whose children are all closed and whose own acceptance criteria are fully met, still gets Closes #N and still closes on merge. The check runs on the PR-body path, so it holds no matter how the mission was started: the auto-issue runner, a "Not complete" retry, the dashboard, orgabot ship, or a hand-run CLI.
An epic runs its children, one at a time. A mission is one worker, one worktree, one branch, one pull request, so a mission launched on an epic can only ever deliver one slice, and it used to report success with every other child still open. Fixing an epic therefore does not start a mission on the epic at all: it starts an epic run, a sequencer over ordinary child missions. The epic itself never gets a worker, a branch, or a pull request, which is also why nothing the run writes can close it. (On a project an organization owns, Fix reaches the newer autonomous epic execution capability described below instead; the sequencer here is what runs when no organization owns the project.)
The run enumerates the epic's open children in the order the epic declares them (GitHub's native sub-issues first, then the phase table or checklist in the epic body), and launches exactly one of them as an ordinary mission. Where a reference sits in the body matters: a #N under a heading such as Related work, See also, Background, or References is context the run reads, not a child it executes, and when the body has an explicit children section (a heading naming children, sub-issues, or tasks, or a - [ ] #N task list) only the references inside it are children. Citing another epic for context therefore no longer pulls that epic's backlog into the run. Evidence sections count as context too: a #N under a heading such as Observed failure, Live examples, Evidence, Repro, Impact, or Root cause is a citation, and so is one on a Refs #N / Closes #N footer line, which is a reference by definition. Naming another issue's epic is not a claim to be one either: the word epic, tracker, tracking, program, or roadmap immediately in front of a reference ("during epic #2257", "follow-up to epic #2257") reads as a citation, so a well-evidenced bug report that cites three or more issues stays a single unit of work rather than inheriting the epic lifecycle. And being wrong in that direction is cheap: a child Orgabot only inferred from body prose, rather than one the tracker itself declares, can never hold a mission. If that inferred subtree cannot be read, it is dropped with a line in the log naming the edge, and the item is executed as the single unit it is. A child you stop with orgabot stop is not relaunched either: the stop is your decision, so the epic reports it as a decision for you to make (drop the child or re-run it) instead of retrying it as a failure. Only a stop or cancel you made counts as that decision: a child Orgabot ended on its own (for example, because its worker process died) is a failure, and is retried like any other. Same role routing, same dependency gate, same verification, review, draft pull request, and merge gates, and each child's pull request closes its own child issue. Nothing is parallelized: the next child starts only once the previous one has finished, because concurrent slices of one program race each other and the loser's pull request ends unmergeable.
A child whose declared blockers are still open is deferred, with the blocking issue named, and the run moves to the next child it can legally start. A dependency cycle among the children refuses the run and names the cycle rather than picking an arbitrary order. A child that fails, holds, or is cancelled stops the run, and the report names the children already delivered, the one that stopped it, and the ones still remaining: "delivered one of seven" never reads as done. Press Fix on the epic again after you have dealt with the blocker and the run resumes at the first open, unblocked child, relaunching nothing it already delivered. An epic with no open children is refused, with the reason. The epic issue is left open in every case; it closes when you decide the program is done.
Because the run keeps going on its own after you start it, it launches children you never individually picked, chosen by #N references in an epic body anyone can edit. So it applies the same two admission rules the automatic issue sweep applies, rather than treating a reference as your selection. A child whose opener is not on your author allowlist (Trusted issue authors at the top of the dashboard's Issues tab, or ORGABOT_ISSUES_ALLOW_AUTHORS / ORGABOT_ISSUES_TRUST_ALL_AUTHORS=1 in the environment) is passed over and named in the report, and so is a child whose opener GitHub did not report: an issue body becomes the instruction driving a worker with git and pull-request authority, and an arbitrary reporter listed in a maintainer's phase table must not reach it. A child you declined is passed over too, because declining is a decision the run does not get to overrule. Press Fix on that child issue directly to run it anyway.
A child whose launch is held is reported as held. Starting a child can be refused before any worker starts: its labels cannot say whether it is a repository change (a bug or enhancement label with no acceptance checklist, or a delivery label next to design or question), no role in your organization can own it, the org token throttle parked it, it waits on independent CISO review, its dependencies or live delivery state hold it, or its last round already concluded it was satisfied. The run then says the child was HELD and why, with the remedy, and never claims it is launching it. It stays active and is re-checked about every ninety seconds, like a run waiting on a child in flight (answering its question clears that pause, so the next sweep acts on your answer), and every held launch is written to the durable decision log under ~/.orgabot/issues/decisions/. When only you can clear the hold, you get a question for that child in orgabot inputs and on the dashboard. For a label conflict, answer it with the shape the child should run as (orgabot answer <id> repository_change, or review_audit or conversational) and the next pass launches the child under that shape; correcting the child's labels works too. The labels win whenever they can decide: once a child's labels settle its shape (a delivery label with an acceptance checklist, or a single design or question label), the next pass withdraws the pending shape question and launches the child on its labels, and an answer you gave earlier no longer applies to it. Your answer only decides a child whose labels still cannot. For a missing role, answer the role prompt and the child relaunches under that role. Either way the run carries on by itself once you answer. A child mission that already failed or was cancelled is a different case: if the only thing left pending on it is an input that does not relaunch it, the run stops on that child (and names the leftover input) rather than claiming that answering it would carry on.
A child that is itself an epic gets its own run rather than a mission, and the outer run parks on it until it finishes. While the nested run is making progress (it launched one of its own children, or is waiting on one in flight) the outer run reports that it is waiting on it, exactly as it would for a child mission in flight. Only a nested run that is itself held, stopped, or finished is reported as a held child, with the nested run's own reason. Two trackers that list each other refuse rather than expanding forever, and so does an epic nested more than three deep. A pass that could not read GitHub at all launches nothing and leaves the run active, so a rate limit delays the program instead of stopping it; the run then backs off, from five minutes doubling up to twenty (and never sooner than GitHub says it will accept requests again, when GitHub says so), rather than re-reading every child every minute. While a child mission is in flight, or a child is held, the run is re-checked about every ninety seconds, never longer, since nothing can change until that mission finishes or the hold clears. The pause is recorded with the run, so every process that sweeps (the supervisor, the dashboard, a scheduled recovery sweep, a CLI command) honors it. When a child mission finishes, its epic is woken directly and its pause is cleared, so the next child usually starts at once; if that wake is lost (the process that finished the child exited first), the next sweep still starts it within about ninety seconds.
Fix is intent-driven: an epic on an organization-owned project executes autonomously. You press the same Fix button on an epic that you press on a bug, and you never pick a mode. What the button submits is the work item plus the intent to fix it; which capability that needs is decided from the item's own normalized type, which is the type the work-item provider reports. For a GitHub issue Orgabot already holds (the dashboard's own Fix button, which classifies from the issue it has rather than spending a provider read), that type is asserted only when the issue is shaped like a tracker: an epic, tracking, tracker, program, or roadmap label or title and the children it names in its body, the same test the epic run itself uses. A label on its own is not enough, because on a public repository anyone with triage permission can add one, and that would let a third party's label, rather than your press, choose the higher-concurrency capability. Such an issue takes the ordinary fix path, and so does an ordinary issue. An epic-shaped item on a project an organization owns routes instead to autonomous epic execution: one governed mission that reconstructs the epic's executable scope through the work-item provider, builds the Work Graph, and drives the children to a terminal disposition, fanning each one out through the ordinary role-owned mission path. Nothing about that path is relaxed: every child still passes its own verification, review, and merge gates, the reviewer is still never the producer, pull requests still open as drafts, and a gate that requires a human still holds for a human and is never self-approved. An epic that reaches such a gate ends HUMAN_ACTION_REQUIRED with a structured Human TODO naming what you must do, and that is a success, not a failure. Children inherit the parent's organization, so autonomy never widens authority; a project an organization owns but whose role cannot be auto-selected holds and asks you to pick one rather than falling back to the unattributed worker. Which role runs each child is read from that child's own declared structure. A child carrying a role: <slug> label (or orgabot-role:; the role's slug or its title, with : or /), or a capability: <id> label that exactly one of your active roles holds, executes under that role; every candidate comes from the same organization that owns the project, so a child can never reach authority the epic's organization does not have. A child that declares nothing inherits the epic's role exactly as it always did, and the mission log says so per child, naming the role and the reason. Reserved governance seats are never taken on a label's say-so. Your oversight roles (owner, platform administrator, CISO, GRC, legal/compliance, finance) are chosen by governed routing acting on your own instruction, never by a label a third party with triage permission could add; a child labelled for one of them holds for you instead, because an assurance role may not produce the change it exists to independently review. Nothing is read from a child's title or body, and nothing is read from a bare topic label either: role: qa-test-engineer routes to QA, while the word "test" in a title, or a plain documentation label, routes nowhere. Two labels naming two different roles, a role your organization has no active seat for, and a declared role nobody staffs are all holds: the child is left out of the graph and raised as a decision for you, never run under a guess. A role is pinned when the child enters the graph, so a later pass that reads the labels differently reports the role the child actually carries rather than the one it would pick today. If the organization chart cannot be read at all, every child inherits the epic's role as before, because an outage must not park your work. The decision is never made from the words in your instruction, and never hard-coded by the surface you pressed.
Autonomous epic execution runs a graph, not a fan-out. The children of an epic usually say what they wait on, in their own bodies, in ordinary words: Depends on #406, Blocked by #405 and #406, Requires #412. Orgabot reads those declarations as dependency edges, alongside the tracker's own native blocked-by links, and builds the Work Graph from them. Only genuinely independent units start together: for an epic whose thirteen children declare a chain, the first wave is the handful with no prerequisites, and each later unit unlocks as the ones it named land. The log and the epic's own status say which units are running now and which are gated by an unfinished prerequisite, naming it, rather than reporting every unit as independently runnable.
The prose reading is deliberately narrow, because a dependency edge that is wrong delays real work. A reference counts only when a dependency keyword introduces it, and only while the words between keyword and reference are connective tissue (a comma, and, issues); the parse stops at the first thing it does not recognise, so Depends on #406 but only after the UI ships in #999 records #406 and nothing else. Code blocks are stripped first, a citation (See also #300, a Refs #301 footer) is never a prerequisite, and an edge that would make the graph cyclic is dropped and named in the log rather than refusing the whole epic: a sentence Orgabot misread must not be able to stop a program.
Discovery reads every child the epic has; it holds only on a genuine ceiling. An epic with a long history can be referenced by far more items than are still work: closed children, shipped incident issues that merely mention it. Discovery examines candidates in windows of twenty-five at a time, classifies each from one tracker read, and moves on. A closed, duplicate, or obsolete child is recorded as already-done scope and releases its slot to the next candidate, so an epic with forty references and five open children runs a wave over exactly those five. A closed item that only mentions the epic is not followed further, so a pull request cited in its body that Orgabot cannot read never drops it as lost scope. The run holds only when the tree is genuinely too large for the total-node or total-request ceiling, a declared child cannot be read, or the tracker search came back incomplete, never because more than twenty-five references exist.
The same primitive from Claude Code: `/fix-epic`. orgabot fix-epic <epic-reference> --project <id> (and the /fix-epic skill that shells out to it) is the explicit harness surface onto the same launch pipeline: the same normalization, the same capability routing, the same Mission Record, and every gate. It differs from the Fix button in one input only, described next: it declares the item an epic, so routing does not wait on the tracker's classification. The reference is owner/repo#123, a GitHub issue URL, or provider:externalId for another tracker (linear:ENG-431, jira:ENG-17), resolved through whichever work-item provider is configured for the project. Because it names the reference explicitly, the invocation is the type declaration: /fix-epic records the item as an epic (marked operator-declared on the Mission Record) and the router selects epic execution without consulting tracker labels. A tracking issue with only a domain label, no epic label in the repository, and its children listed in a comment launches as an epic; before, it held before launch until someone invented a label for it. Discovery still finds the children at execution time, from native sub-issues, the body, comments written by the item's own author (a third party's comment cannot widen the epic's scope), and items that reference it; an epic with none holds with epic_no_children and names what to link. Comments and the body are read for GitHub (#N), Linear (ENG-123), and Jira (PROJ-17) epics. A Linear or Jira key counts only when it is a whole, upper-case key with the epic's own team or project prefix, and a link counts only at the tracker's own issue position (linear.app/<workspace>/issue/ENG-7 in the epic's own Linear workspace, or /browse/PROJ-7 on your configured Jira site, including smart-link cards); words inside a link's slug never count, and neither does a lone reference such as "Predecessor: ENG-99". The text of a markdown link never counts either: [ENG-2](url) names a child only when its target is itself one of those issue links, so a link to a pull request, a document, or another Linear workspace names nothing whatever it says. Quoted text (a > blockquote, at any depth) is never read. A team or project whose key is a common word or abbreviation (API, UI, HTTP, TLS), or is only one or two characters long, is read more strictly so that "API-2 then API-3" in prose is never taken for two children: its keys count only when they lead a list item (- API-2, - [ ] API-2, 1. API-2), lead the first cell of a table row or a cell in a column headed "Issue", "Key", "Ticket", "Child", "Task", or "ID" (a key in any other column, such as a version matrix, is ignored), or appear as a tracker link. Jira bullet lists, numbered lists, and tables in a description or comment are read the same way. List such an epic's children as a list, a task list, a table, links, or sub-issues. One case is still read as children: a list whose items start with such a key but is not a child list, for example "Supported:" followed by - API-2 compat; put it under a heading such as "Background" or "Related work", or keep the epic's real children under a "Child issues" heading. A comment the tracker marks as a bot's is never read, even when a bot filed the epic itself, so a bot-filed epic's children must be in its body, its sub-issues, or items that reference it. If every child discovery found for a declared epic turns out to be unreadable (deleted, private, or outside the credentials the project's tracker connection has), the mission holds with epic_children_unreadable and names them, rather than executing the whole epic as one unit. If those reads failed because the tracker itself was unreachable or rate-limited, discovery is retried with backoff for a few minutes; if the tracker is still unavailable after that, the mission holds with epic_discovery_unavailable, naming the tracker as unavailable rather than the children as missing. Nothing retries discovery after that hold on its own: rerun fix-epic once the tracker answers. The recovery sweep does re-read the epic's state, though: if the epic is closed as completed and none of its child missions is still running or queued, the held coordinator is ended as resolved (see Held missions are re-read against their source). The dashboard's Fix button and orgabot mission are unchanged: there the type is genuinely unknown and the tracker still decides. A reference that cannot be resolved (no provider configured, a read that failed, nothing there) reports that and launches nothing rather than guessing. A project no organization owns is refused with the reason, because epic execution has no role to inherit as its authority boundary. A GitHub reference in a repository the project provably does not own is refused before anything is read from it, and so is one Orgabot cannot check because it could not read the project registry: an unreadable registry is not evidence that the reference is in scope.
Reviewed work is shippable before it is assessed. After verification passes and independent review approves, the round writes its pending delivery record before the acceptance inspection runs, so a host that dies in that step leaves a branch orgabot ship can deliver rather than one that has to be pushed by hand. The inspection itself is bounded: the diff handed to the assessor excludes binary files and is size-capped (a change over the cap is summarised by path), the reads have a wall-clock bound, the assessor call has a hard timeout even when the provider ignores its abort signal, and the log says acceptance inspection over N file(s) when the step is entered. An inspection that times out is recorded as acceptance unavailable (the PR opens with Refs, never Closes), not as "0 of N criteria confirmed". The step that picks which observed tests exercise the changed files parses each source file once and computes each test's reach once, so a delivery over a large tree finishes in seconds rather than pinning the mission at full CPU; it logs acceptance test selection: N of M observed tests examined while it runs, and if it cannot finish inside its own wall-clock bound it too is recorded as unavailable, with the test and file counts, and retried.
An assessor that is down is retried for days, not judged. When the independent acceptance assessor is unavailable (an executor quota or session limit, a crash, a timeout, or no assessor configured), the delivery records acceptance: pending-reassessment rather than a final "unconfirmed". The two-minute in-line retry only ends that delivery round. A durable re-assessment then retries on its own with a growing delay (about 15 minutes, then 30, then hourly multiples, never longer than a day between tries), so an outage that lasts hours or days resolves without you. The assessor walks your whole executor fallback chain, and a rung that fails on its configuration (not signed in, an unknown model id) is skipped rather than ending the attempt. The time allowed for one assessor call grows with the size of the inspection and the number of criteria instead of being a flat 90 seconds. To reconcile a backlog yourself, run orgabot acceptance reassess <mission-id> or orgabot acceptance reassess owner/repo#N (or press Re-assess acceptance on the issue in the dashboard). It re-runs the same independent inspection against the merged head: when every criterion is evidenced it closes the issue, otherwise it comments the precise remainder, and when the assessor is still unavailable it writes nothing. It refuses a delivery whose pull request is not merged or whose verification and independent review were not both observed, and the assessor never sees the worker's own report.
The schedule covers every merged delivery whose acceptance is not yet known. That includes a mission you already accepted while its assessor was down, an epic child settled by its merged delivery, and older merged deliveries whose pull request said Refs only because the assessor never answered: the recovery sweep finds those and adds them to the schedule. Each scheduled attempt re-runs the independent inspection on the merged head and closes the issue or posts the remainder, exactly like orgabot acceptance reassess. It never re-runs your tests or a review on its own; a delivery that would need that is taken off the schedule with a note telling you to run orgabot acceptance reassess <mission-id>. orgabot acceptance pending lists what is waiting, with the number of attempts and when the next one is due (--all includes finished ones), and the dashboard's Issues tab shows the same list under Acceptance pending re-assessment, with a Re-assess now button per delivery. An epic's reconciliation reports such a child as pending re-assessment (not yet known), never as unmet criteria, and counts it as accepted once a re-assessment has evidenced every criterion its issue currently lists. While every node of an epic has delivered and the only thing left is such a re-assessment, the epic reads awaiting acceptance (status waiting), with a line such as "all nodes delivered; acceptance pending re-assessment for 3 item(s)", in both orgabot objective show and the stored objective, and it completes on its own once those verdicts land. It reads blocked only when something else is unconfirmed: a rendered unmet verdict, a delivery with no confirmed decision, or a child whose re-assessment schedule ended without a verdict (run the orgabot acceptance reassess command it names). The assessor is never the role that delivered the work: if your Chief of Staff delivered it, only the installation-level assessor may assess it.
A merged delivery with no recorded evidence is re-checked, not refused. If a delivery merged without its round recording verification and independent review on the merged commit (for example after a crashed round was re-driven, or a conflict was resolved by hand), orgabot acceptance reassess now re-runs the project's verify commands on that exact commit and runs a fresh independent review of the pull request. Only a passing run and an approval of the whole diff count; anything else is reported as a refusal with what was observed. Expect each such re-assessment to take as long as a full test run plus a review.
Missions started with `--no-org` get an assessor too. They have no organization and so no Chief of Staff to choose one, so the assessor comes from an installation-level setting: orgabot acceptance assessor set --provider claude-code [--model <m>] [--fallback codex]. orgabot acceptance assessor shows it and orgabot acceptance assessor clear removes it. When there is none, orgabot doctor reports acceptance assessor (no-org): FAIL: NOT CONFIGURED with the command to fix it, and this is a required check: the overall verdict is doctor: FAIL and the exit code is 1. The escalation for an affected mission says there is no organization and no installation-level assessor, and names this setting. An organization mission whose Chief of Staff has no chosen executor also falls back to this setting before reporting that no assessor is configured.
A merged child is never shipped twice. A child's pull request opens with Refs #N whenever its acceptance came back unconfirmed, so GitHub leaves the child issue open by design once the PR merges. Epic discovery used to read that open issue as work never attempted, and a second fix-epic run on the same epic (after a hold, or after a restart) dispatched every such child again: the same change merged twice under two pull requests. Now a re-run first adopts the persisted Work Graph an earlier coordinator built for the epic, so completed children stay completed and a parked child carries over instead of being orphaned; and, independently of that graph, any open child whose mission was observed to merge a pull request for it is admitted as already delivered, under that mission, and is not dispatched by any coordinator. The epic's log names it (was already delivered by mission ... while its tracker item is still open), and the epic then holds on that delivery's acceptance confirmation rather than re-doing the work. Releasing a held coordinator for a relaunch also releases the project write leases its waiting children hold, so the new run's launches do not queue behind a child parked on a question nobody can answer; a child answered back to life takes a fresh lease at launch. And a transient failure reading the epic itself (a token mint that died with fetch failed, a reset connection) is retried with backoff for a bounded wall-clock budget before the coordinator holds on epic_discovery_unavailable, the same rule the review loop already follows. A GitHub rate limit ("API rate limit exceeded", or a secondary rate limit) on the epic read or on discovery's reads is treated the same way: it is an outage, not an answer, so it is retried, waiting at least as long as GitHub's retry-after or reset says, within the same budget. When GitHub says the limit resets after the budget would end, the coordinator holds right away instead of spending requests against an empty quota. A 403 that is a real refusal ("Bad credentials", "Resource not accessible by integration") is never retried and holds immediately.
A running epic is never replaced, and a child is never built twice. An epic coordinator records no branch of its own (its children do), so re-running fix-epic on an epic whose coordinator still has any child that is not finished is treated as the epic already running: nothing is released or relaunched, a new --instruction replaces the stored one, and the command names the children still in flight. A child whose status cannot be read, or an epic graph that cannot be read, counts as in flight, so a coordinator is only released on a positive reading that nothing is running under it. When a coordinator with nothing in flight is released for a relaunch, the release is recorded as a hand-off, not as your cancellation: its Work Graph does not settle, and the fresh coordinator adopts it. If that relaunch holds for a role answer instead of starting (routing could not pick a role, usually because the routing executor is down), the held mission is the successor: the graph is left as it is, and once you answer the role question (orgabot answer <request-id> "<role>") the resumed coordinator adopts it, completed children included. Until then nothing drives the graph, and if the role request is dismissed (orgabot answer <request-id> --cancel) or never answered, it stays live with no coordinator until a later fix-epic run adopts it or you cancel it with orgabot cancel <released-coordinator-id>. Any other relaunch that does not start (a refused role, a launch failure, or a launch held on memorialization or quota, whose resume starts a plain mission rather than the epic coordinator) settles the released graph instead, through the same cancellation orgabot cancel <objective-id> performs, with the reason on the record, so nothing keeps advancing it without a coordinator; re-run fix-epic once the cause is fixed or the hold clears, and merged children are still not dispatched again. Before settling, fix-epic checks the graph's children again under the objective-store lock, and settles nothing in three cases, each of which leaves the graph live with no coordinator until a re-run adopts it or you cancel it: a child launched after the release is still in flight (the command names it and the orgabot cancel command that would stop it; a re-run adopts the graph and that child), the children cannot be read, or the objective-store lock is busy because a child launch is being admitted at that moment. orgabot cancel and orgabot stop on an epic still end the plan and its children, whether you name the graph's id or the mission id of the coordinator driving it now (a coordinator that adopted an earlier graph drives it under that graph's id, and names itself on the graph before its first pass does anything else, so cancelling it by its own id reaches the children even when that pass was parked on the token throttle or a busy objective store). The graph's id is the mission id of the coordinator that first built it, and it stays a valid handle after a successor adopts the graph: orgabot cancel <released-coordinator-id> cancels the adopted graph and its children, and so ends the successor's work too. That is intended, since the id names the plan, not only the released mission. Independently of any graph, a coordinator never launches a child for a work item that already has a mission in progress, whoever launched it: it adopts that mission into its own graph and logs is already being built by mission ... this coordinator adopts it. A cancelled epic whose children are still running no longer reads as "nothing here is waiting on you": orgabot status says how many children are not finished, which are running and which are held, which coordinator adopted them, and which have no coordinator, with the orgabot stop command that ends those.
An epic never has two live Work Graphs. A coordinator that cannot read the objective store does not treat that as "no earlier graph": it retries with backoff and, past its budget, builds nothing on that pass and logs built no Work Graph on this pass; the next controller pass tries again. Likewise a pass that cannot read mission history launches no child (launched no child on this pass), because without it every child would read as unbuilt. If an epic already has two live graphs (a coordinator built its own before this fix), the coordinator driving one retires the other once its coordinator has ended or was released and every child still in flight under it has been adopted: it is cancelled through the orgabot cancel <objective-id> path with the reason on the record, its queued launches are revoked, and no child mission is ended. The log names it (retired Work Graph ...), or says why it is kept for now. A child that a plan revision replaced under a new node id is adopted the same way as the original node, so its launch is not re-queued on every pass.
A queued child launch never starts a second mission for the same child. A child launch the token throttle or a project write lease parked is a queued entry, not a mission, so releasing its coordinator used to leave it queued, and a later orgabot throttle resume-due (or the supervisor's own drain) started it beside the child mission the fresh coordinator had adopted. Now releasing a coordinator settles every queued launch it owns, logging settled queued launch <id> ... owned by the released coordinator; the fresh coordinator re-derives the launches it needs from the graph it drives. Independently, every drain re-checks a queued launch just before starting it: when its work item already has a mission that is running or waiting, or its coordinator was released or ended while a different live coordinator now owns the epic, the entry is settled with that reason on it (orgabot throttle queue shows it) and nothing starts. When the mission or objective store cannot be read, the entry launches nothing and stays queued for the next drain.
An acceptance criterion is confirmed by the test that actually exercises its surface. The independent inspector cannot cite a test unless that test exercises the named file: through its imports, or by opening the file as a content test does (readFileSync('src/pages/OtherProjects.tsx') and a word count is how a "page is thin" criterion is checked, and it imports nothing). A test the inspector itself lists as observed passing therefore counts for the file it reads, a route or page name the inspector cites as a surface is resolved to the observed file when it names one uniquely and otherwise treated as a label rather than as a surface no test reaches. The full issue thread is sent to the inspector as context for every criterion, but it is never itself a scored criterion (issue 2644), so no synthetic "confirm the whole request" clause can hold a delivery. A positively observed omission is still unmet, a cited test that was not observed passing is still refused, and a criterion that names a file still needs an observed test for that file; what changed is that a delivery with its own passing test for the named page no longer reads as "0 of N confirmed" and demotes its PR to Refs.
An epic can carry your guidance for every child. A whole-epic run often needs one instruction that applies across all of it and belongs to none of the children: "a prior attempt already landed and merged pull requests for several of these; inspect the existing branch or PR for each child first and fill the gaps instead of restarting", or "skip any child that needs input only a human can give, and record exactly what is needed for the final report". Pass it as orgabot fix-epic <ref> --project <id> --instruction "<text>" (or --instruction-file <path>). Orgabot records it on the epic's own mission record and appends it to each child mission's instruction as a demarcated block that names itself as operator context and states that it does not replace, widen, or narrow the child's own definition of done; every gate still applies to the child exactly as before, and the text grants nothing, the same rule every typed instruction lives under. Because it lives on the record, not the launch, a child that starts later, after a hold is answered or a partial run is resumed, inherits the same text, orgabot status and the epic's log show it, and the epic's reconciliation carries it so the final report reads against what you asked. The epic's coordinator has no branch of its own, so orgabot follow-up refuses it and names the remedy: re-run fix-epic on the same epic with the new --instruction, which replaces the stored text for children that have not started yet and leaves running children on the text they launched with.
An epic can keep its work off the default branch. When an epic's work must land on an integration branch instead (the default branch deploys, say), declare it with orgabot fix-epic <ref> --project <id> --integration-branch <branch>. Every child then builds from that branch, opens its pull request against it, merges conflicts from it, and is merged into it; the ship step, the review loop, conflict remediation, and the merge itself all read that base from the mission and never fall back to the default branch. If a child's PR turns up on any other base, Orgabot retargets it to the integration branch and re-reads it before doing anything else, and if GitHub refuses the retarget the merge is refused and the mission holds naming why. It is never merged into the default branch. Re-running fix-epic with the flag on an epic that is already running applies it to that epic. A retry of a child that failed before it had a branch of its own, whether you start it with orgabot remediate or the epic relaunches it, branches a fresh child branch off the integration branch; it never works on the integration branch directly. The epic follows such a retry: when a retry you started delivers, the child's node completes with it, and when a retry was refused for being pointed at the integration branch itself, the epic relaunches the child correctly once. See the CLI reference for the details.
The launch says which scope it actually got. Every orgabot fix-epic launch prints a readback line before anything else: the resolved work item and its normalized type, the selected capability (epic_execution for an epic, issue_fix for ordinary work), the workflow, and the routing state. It is read back off the Mission Record, never inferred from the command name, so an epic that resolved as an ordinary issue is visible at once instead of after a partial pull request merges. On the dashboard's Fix button and orgabot mission, a type the tracker leaves ambiguous reports routing: held and starts no worker: label the item and retry. fix-epic never holds on the tracker's type, because the command itself declares it.
A role assignment changes who owns the mission, not what it does. When no role can be auto-selected the launch HOLDS and waits for orgabot answer <id> "<role>", or for the same prompt answered in the dashboard. The resolved work item, its type, and the capability all survive that hold, so answering it resumes the same scope a direct launch would have had: an epic resumes as epic execution, and an ambiguous type stays an open decision rather than quietly becoming ordinary issue work. Both answer surfaces print the same readback line the launch does, so the resumed scope is visible from the terminal and from the browser alike.
Only the body closes anything. A closing keyword in a pull request's title links nothing while the PR is open, so a title reading Fix #1290 looks harmless. It is not: when the PR is squash-merged, GitHub reuses the title as the commit message on the default branch, and there Fix #1290 is a closing keyword that closes the issue no matter what the body decided. That is how an epic was closed past its own Refs footer. So no title Orgabot composes carries one. An issue-fix mission is titled Fix issue 1290: "<the issue title>", with the words issue 1290 rather than #1290, which reads the same and closes nothing; a keyword copied from a typed objective or from issue prose is rewritten the same way, at the moment the PR is opened. Branch commit messages were already rewritten this way, which covers the remaining routes onto the default branch: a squash merge copies them into the commit body, and a rebase merge lands them verbatim. The body's one Closes #N / Refs #N footer is the only place the decision is made.
The ids link back to the dashboard. The mission id and the correlation id name the timeline, worker log, and verification evidence behind the change, so each is rendered as a link to the dashboard page that shows it: #missions/<id> for the mission, #work/correlations/<id> for the correlation trace. The id itself stays the visible link text, so the description still reads (and greps) the same. The link points at http://127.0.0.1:4317 by default, which is where orgabot dashboard runs; if you run the dashboard on another port or reach it through another host, set ORGABOT_DASHBOARD_PORT or ORGABOT_DASHBOARD_URL in the environment that launches missions, and Orgabot writes that origin instead. No dashboard token is ever put in a link.
Three things it deliberately leaves out:
- The worker's brief. A mission instruction is mostly machinery: the role behavioral contract, organization memory, scoped context, untrusted-data fencing, and delivery boilerplate ("open a draft pull request", "do not run git"). None of it belongs in a description a human reviews, so the Problem paragraph is recovered from the request itself, and for an issue-fix mission that is the issue's own text.
- Verification and review evidence. Gate output and security-review findings are long and belong in the PR's comments, where they still are.
- Live references to other work. An
#N, a cross-repositoryowner/repo#N, or a GitHub issue/PR URL copied out of quoted prose is rewritten to a plainissue N, so mentioning a related ticket no longer attaches it to this pull request in GitHub's sidebar or in the dashboard's PR cards. Nor can quoted text close someone else's issue when the PR merges. (GH-Nlooks inert but is not: GitHub lists it alongside#Nas an issue reference syntax, so Orgabot does not use it.) The oneCloses #N/Refs #Nfooter is threaded from the mission record, and it is the only live reference the body carries. - Notifications to people who were only quoted. Issue text is written by whoever opened the issue, and a worker summary quoting it is no more trustworthy. A
@handlein either loses its sigil and reads asat-handle, so copying it into a description does not subscribe or notify that account from Orgabot's identity. Both paragraphs also have control characters stripped (a terminal escape in an issue body would otherwise reach your terminal through the CLI's echo) and any credential-shaped string redacted the same way Orgabot redacts its logs.
PR-state classification
After review approves, Orgabot reads the PR's live GitHub state and classifies it, then tries to proceed on its own instead of stopping at the first obstacle:
READY_TO_MERGE, BEHIND, CONFLICTING, CHECKS_PENDING, CHECKS_FAILING, CHANGES_REQUESTED, AWAITING_REVIEW, DRAFT, MERGED, CLOSED, UNKNOWN.
| State | What Orgabot does |
|---|---|
UNKNOWN | Mergeability is computed asynchronously by GitHub, so a fresh PR often reports this. Orgabot polls with backoff instead of bailing. |
BEHIND | Updates the branch against its base and re-checks. |
CONFLICTING | Merges the base in; commits and pushes the resolution when the conflicts are unambiguous. |
CHECKS_PENDING | Polls until checks finish. The wait is bounded by wall clock (45 minutes), never by a step count, so a long required suite is not mistaken for a stuck PR. |
CHECKS_FAILING / CHANGES_REQUESTED | Routes back into the same independent review-then-fix loop. |
Strict branch protection is mechanical, not a hold
A base branch with strict protection (required checks that must pass on a head that is up to date with the base) refuses two merges that need no decision from you:
- Checks not yet registered. Immediately after a push there is a window in which GitHub has not created the required check runs on the new head, and the merge fails with
N of N required status checks are expected. Orgabot waits, re-reads the PR, and merges once the checks report. - Behind the base. A sibling PR landed while this one was in review, and protection refuses with
the head branch is not up to date with the base branchorthe base branch policy prohibits the merge. Orgabot updates the branch from the base (gh pr update-branch, or a local base-merge when the API path is refused), waits for CI on the new head, re-runs the pre-merge gate on that head, and merges.
Recovery is bounded to four attempts per merge because a sibling can keep landing; past that the PR holds with the refusal it last received. Only refusals that genuinely need you (an identity without merge rights, a reserved review, real conflicts the resolver could not clear) surface as operator attention.
Handing a blocked delivery to a covering role
A role-owned mission whose accountable role cannot deliver (its GitHub App installation does not cover the target repository) holds fail-closed, and the hold reason names the roles whose installations DO cover it. orgabot ship --deliver-as <roleSlug> is the operator's way to act on that: the named role delivers the built work under its own pre-existing grant. The hand-off is one hop, never chained, never chosen by Orgabot on its own, and never a widening - a receiving role that cannot mint for the target holds exactly like the original. The organization audit log records the delegation ("role A produced the work; role B delivered it at the operator's direction"), so the production is never re-attributed.
Safety. Orgabot never force-pushes over or discards human commits. When a conflict is not safely auto-resolvable it holds with the specific conflicting filenames so you can finish by hand. Both orgabot status and the dashboard show the specific state, not just "held".
Conflicts are remediated from a hold, not only after approval
A pull request goes CONFLICTING whenever a neighbouring PR merges and moves the base underneath it, which has nothing to do with whether this review approved. So the review loop checks the PR's live state at every round boundary and dispatches the same edit-only conflict resolver even while the mission is held for an entirely unrelated reason (a coverage gap, a scanner that could not obtain evidence, a pending operator decision on a fix the loop could not get approved). Clearing a conflict restores a mergeable branch on which the gate that is actually blocking can be re-attempted on a current head.
What that path will not do:
- It never merges, and it never clears a gate. Resolution moves the head SHA, so any earlier review result is stale by definition and the gate is re-evaluated against the new head. An approval is never carried across a resolution, and blocking findings pinned to the old tip stay pinned to the resolved one.
- It never takes a conflict away from its human. A branch no Orgabot mission created is reported and left alone, and a resolver refusal that names human ownership is not retried in any later round.
- It never spins. Remediation from a hold is bounded to two attempts per review loop. Past that, a base branch that keeps moving is reported rather than chased.
- It is skipped entirely under `--no-merge`. A review-only pass does not touch the branch.
When remediation cannot clear the conflict, the PR holds with its original blocking reason first and the conflict appended: the conflict is why it cannot merge, but the original gate is still why it is not approved.
conflict-remediate has known rough edges: it can report "no conflicts" on a draft PR, and it can declare a non-compiling resolution successful. Build the resolved branch yourself before trusting it.Verification around the merge
- Pre-merge verify guards the merge when a project has a verify/test command: it runs that suite on the PR branch right before merge. A red run holds the PR open.
- A host suspension is not a verification result. If the machine sleeps while a pre-merge run is executing, the command is killed under it and nothing judged the tree. The loop reports that as its own outcome (
pre-merge verification did not finish), opens no fix round, counts no strike, and re-runs the same suite once the host is awake, backing off the same way a scanner evidence gap does. The merge then stands on the re-run's evidence. Only an operator stop during that wait leaves the PR held. - A dropped network or a busy lock is not a verification result either. When a pre-merge run fails and its own closing error names a DNS or connection failure (
ENOTFOUND,EAI_AGAIN,ECONNRESET, a fetch timeout) or a busy Orgabot state lock, and no test runner reported a failure, the loop reportspre-merge verification was unavailable, opens no fix round, counts no strike, and re-runs the same suite on the same head with backoff for up to 15 minutes. If it is still unavailable after that, the PR is held open with that reason; it is never handed to a worker to "fix". - Verify is optional. If a project has no configured verify command and no auto-detected test suite, pre-merge verification is skipped (evidence records that nothing was machine-verified). That skip does not leave the mission waiting and does not block merge when review and autonomy/approval policy otherwise allow it.
- Post-merge verify confirms the merged result on the base branch when a suite exists; the same optional-skip rule applies.
- The mission's own pass counts as one of the runs.
--verify-runs N(default 2) asks for N independent green observations of the commit about to land, and the mission's own verification is one: a full local pass, bound by SHA to that exact commit, executed in a different checkout, which is what a flakiness guard actually measures. So when the mission recorded such a pass for the commit the gate checked out, the gate runs one fewer attempt: at the default, its own full run plus the recorded pass, instead of a full run plus a test-only re-run. Two independent greens either way, one suite execution fewer. The gate always runs at least one attempt of its own, whatever the reuse says: only the full run installs dependencies and builds in its fresh worktree, and that checkout is the only place a clean-checkout failure shows up at all, so--verify-runs 1plus a recorded pass still runs it. The reuse is admitted only after the checkout exists and reports the same commit, so a push landing in between cannot leave the gate standing on a pass for a commit it is not measuring. The log records it as evidence and names the commit; it is not a silent skip. Anything weaker runs every attempt: no recorded pass, a pass observed by CI instead of locally, a light-profile pass, a commit some later run recorded as red, or a SHA that moved. A repair, a follow-up, or an extra commit after the mission verified moves the SHA.
- CI is an authority, not an overwrite. After the pull request opens, Orgabot observes GitHub Checks for that exact head. When required checks exist, that observation is the mission's verification record for the head it names: green satisfies CI authority, red fails, and a green result on a different SHA never counts. When the pull request has no checks at all, the observation is absent rather than failing, so a local full-suite pass for the same head stays the mission's authoritative record. The absent observation is still shown on the mission, as "CI observed (not the gate)", so a green ship never hides the fact that CI was not what gated it.
Post-merge verify pins both runs to the merge commit GitHub reports for this PR (mergeCommit.oid), then on a red combined tree re-runs the same checks against that commit's first parent (the base without this merge). Baseline red means the break was already there: the mission is not held, but the break is still recorded. Baseline green (or unknown) attributes the break to this merge and flags needs-attention.
The combined run is skipped when there is no combining to check. Post-merge verify exists for the sibling case: two pull requests, each green against an older base, git merges both, and the combined tree is red. When nothing else landed, the squash commit's tree is the tree the pre-merge gate already measured, so the run is skipped: the merge commit's tree object must equal the tree of the pull-request head the gate reported checking out, and the merge commit's first parent must still be the base tip this mission merged onto. A sibling merge changes the base, which changes the squash tree, so it cannot pass either test. Anything else runs the suite and says why in the log: a different tree, a moved first parent, an unreadable tree or parent, or a pre-merge gate that measured nothing itself, which today means a project with no verify or test command at all. The skip is recorded as an absent post-merge observation, with the tree identity it stands on in the summary. It is not recorded as a pass, because no suite ran on the merge commit and a record that looked like one would put an observation that never happened into the mission's acceptance evidence. It is not a failure either: nothing is held, and the mission's post_merge acceptance is satisfied by the tree identity itself, which was observed, with the derivation stated.
If several unrelated missions report the same failing test after merge, still suspect the base first and fix it.
CI-primary projects
Some repositories have a strong Actions pipeline and a local suite that costs minutes and gigabytes. For those, the local plane can do what it is fast at - thinking, editing, light validation, review, and PR orchestration - and let CI do the expensive authoritative compute.
Set it with the ci_primary preset:
orgabot project verify-policy <project> --preset ci_primary
orgabot project verify-policy <project> # what gates this project todayIt resolves to:
| Phase | Authority | Profile |
|---|---|---|
| post_implement | local | light |
| post_repair | local | light |
| pre_ship | CI | - |
This is not deferred_ci. Under deferred_ci nothing validates locally at all, so the first signal on a broken change is a red pull request. Under ci_primary a cheap local gate still runs on the way in, and only the authoritative build/test/integration compute moves to CI.
No ritual duplication
When CI is the project's configured pre-ship authority and it has recorded a verified pass bound to the exact commit the merge gate checked out, that observation supplies one of the gate's required green runs instead of a second identical suite.
Every clause there is load-bearing, and the reuse refuses if any of them is missing:
- CI must be the configured authority. A repository that merely runs Actions has not nominated CI as its gate.
- The evidence must be a verified pass.
awaiting_ciis explicitly not a pass, and never satisfies anything. - It must be bound to the commit the gate itself checked out, as reported by its own checkout, not a tip read beside it. A push landing in between refuses.
- The project must not have asked for both authorities to observe the commit independently:
``bash orgabot project verify-policy <project> --preset ci_primary --require-both-authorities ``
The gate still runs at least one full attempt of its own, in a fresh worktree, whatever the reuse says. That run is the only place a clean-checkout failure shows up at all - a dependency the lockfile does not carry, a fixture that is gitignored, a build step the mission's worktree happened to have warm - and CI observing a build somewhere else cannot speak for it.
A red run hands over findings, not prose
When CI fails, Orgabot parses the failing checks' logs into structured findings before dispatching a repair: the workflow, the job, the step, the kind of failure (compile, test, lint, dependency audit), and the file and line it names. The repair brief leads with those and with the list of files they point at, and keeps the raw log underneath as the evidence the parse came from.
A failure whose shape Orgabot does not recognize still becomes a finding carrying its excerpt. A red check never produces an empty findings list, because "no findings" reads as "nothing wrong".
Both the findings and the logs are labelled untrusted data in the brief. CI output on a public repository is written by anyone who can open a pull request.
Per-project review policy
Projects can require extra scanners and checks:
orgabot project review-policy <project> [--add-scan '<cmd>'] [--rm-scan <id>] [--replace-scan <id>='<cmd>'] [--add-check <id>] [--set-missing <id>=skip|block]or set them at registration with --review-scan, --review-check, --no-default-scanners.
Scanners are scoped by declared capability, not guessed
Each scanner in a review policy declares whether its command accepts explicit path targets (pathArgs). Orgabot scopes a scanner's per-tip scan to the pull request's changed files only when the scanner declares that support - the two built-in scanners default to semgrep scan and osv-scanner, both of which accept paths, while semgrep ci (which takes no positional targets) and any scanner with no declared capability default to not scopable and always run against the whole repository.
An unscoped (whole-repository) run is never silent: it is logged with the reason, and the same reason lands in the evidence the reviewer reads, so a "clean" scan can always be told apart from one that simply never ran narrow. orgabot doctor also reports any registered scanner whose configured command cannot be scoped, naming the project and, for semgrep ci, suggesting the scopable replacement. If your project was registered before this existed, run orgabot doctor once to check whether its scanners are being diff-scoped at all.
The suggestion is derived from the command you configured, and it keeps that command's own rules: semgrep ci --config security/custom.yml is suggested as semgrep scan --config security/custom.yml --error, never as a fixed default that would silently drop your rules. Where the configuration cannot be translated safely - semgrep ci with no --config at all draws its rules from the CI policy configured on semgrep.dev, and no local semgrep scan command reproduces that, and an argument such as --baseline-commit has no scan equivalent - doctor emits no command and asks for a manual migration instead, naming what it could not translate.
The repair it suggests is --replace-scan <id>='<cmd>', which swaps one scanner's command in place. Use it rather than --rm-scan plus --add-scan of the same id: additions are applied before removals, so that pair deletes both records and leaves the project with no scanner at all, and a re-added scanner would come back onMissing: skip even if the one it replaced was mandatory. --replace-scan keeps the id and the blocking disposition, and touches nothing else in the policy.
A diff that touches no path any scanner could act on - a change that is documentation prose (.md, .markdown, .rst, .adoc) end to end - skips every scanner outright rather than running one whole-repo or reporting a synthesized "clean" result. The evidence records no scannable paths in this diff instead.
That skip is deliberately narrow, and it is about CONTENT, never about where a file sits. A directory name is chosen by whoever opens the pull request, so no fixtures/, __fixtures__/, testdata/ or test-fixtures/ path waives anything: fixtures/package-lock.json is a real dependency manifest and testdata/exploit.ts is real executable code, and both are scanned. .mdx is scanned too, because MDX embeds executable JSX, and so is .txt, because requirements.txt and constraints.txt are dependency manifests OSV reads. And the skip only applies when every configured scanner is one whose subject matter is code and dependency manifests alone. Only osv-scanner is that by default: it reads lockfiles and SBOMs, and a .md file is not one of its inputs whatever it is configured with. Semgrep is not, even though it ships as a default: semgrep's rules decide what it reads, its generic-pattern mode matches human-readable text, and projects do configure it to find credentials in a README - so a documentation-only pull request still runs it. A project that knows its own semgrep rules cannot fire on prose says so explicitly with proseInsensitive on that scanner in its review policy; nothing is inferred from the scanner's name. Add your own scanner with --review-scan and nothing is ever skipped on path names either, because Orgabot cannot know that your secret scanner has nothing to say about a markdown file. The moment a diff contains even one non-prose path, every configured scanner still runs and its findings are still required evidence.
osv-scanner is scoped differently from the rest, because it reads dependency manifests, lockfiles and SBOMs rather than source code. It is given only the package sources a pull request changed (package-lock.json, yarn.lock, go.mod, requirements.txt, pom.xml, an SBOM, and the rest of the inputs it supports), and on a diff that changes none of them it is skipped, recorded as such in the evidence. Handing it a TypeScript file is not a narrower scan: OSV answers that with exit 128, "No package sources found", which is not a findings code, so a blocking osv-scanner would hold every ordinary source-only pull request on evidence it never gathered. The same exit 128 is read as a skip wherever it still arrives - an empty lockfile produces it too. No other scanner is narrowed this way: semgrep's rules decide what semgrep reads.
A scanner command that names its own inputs is never skipped or narrowed at all. osv-scanner scan source --lockfile requirements.txt:deps.txt assigns a parser to a filename you chose, and Orgabot cannot tell from that name which of your changed files the scan covers - so it runs the command exactly as you configured it, with the reason recorded, rather than deciding the diff has nothing for it to read. That holds whatever the file's extension is: because you assigned the parser, --lockfile requirements.txt:deps.md makes a Markdown file a dependency manifest, so the documentation-prose skip above never applies to a command that names its own inputs either.
A diff is only skipped when Orgabot is certain none of its files is a dependency file. OSV keeps adding supported inputs, so a changed file that is dependency-shaped but not on Orgabot's list of recognized package sources - an unfamiliar *.lock, a *.gemspec, a package.json, an SBOM under a name Orgabot does not know - is treated as uncertain, and uncertainty runs the whole-repository scan with that reason logged, never a skip. SBOMs are recognized in every serialization OSV reads, not JSON alone: *.spdx.json, *.spdx.yml, *.spdx.xml, *.spdx.rdf, *.cdx.json and *.cdx.xml are package sources, and any other SPDX- or CycloneDX-shaped name is uncertain. Findings from such a run are still scoped to the pull request's own diff, so it cannot block you on pre-existing lockfile debt you did not touch.
Deep (whole-repository) scans
orgabot review deep-scan <project> # every configured scanner, whole-repo, onceThe per-tip scan above only ever sees one pull request's own changed files, so it cannot catch a vulnerability introduced by an earlier PR in a chunk and only reachable through a later one. orgabot review deep-scan runs every configured scanner against the whole repository in one pass - intended to run once after a chunk or epic finishes rather than per pull request - and files what it finds as its own issue.
This adds to the per-tip gate above; it never replaces it. A merge still requires in-diff scanner evidence on the exact tip it merges, because a vulnerability sitting on main between the third and twelfth PR of a chunk must not wait for the twelfth PR's own scan to notice it.
"Clean" requires a completed run from every configured scanner. If the branch checkout fails, a scanner binary is missing, or a scanner is skipped, the pass reports ✗ incomplete scan with the reason and exits non-zero - it never prints a clean bill of health for scanners that never ran.
The filed issue carries finding metadata only: which scanner, its disposition, and how much output was withheld. It never carries the scanners' raw output, because an excerpt quotes the source line it matched, and that line can be a credential or an undisclosed vulnerability - published to everyone watching, on a public repository, the moment the command runs. The raw output is retained instead in an owner-only file under $ORGABOT_HOME/deep-scans/, whose name the issue gives; the terminal prints its full path. Publishing the raw output is an explicit decision by a human who has read that file:
orgabot review deep-scan <project> --disclose-output # publish the raw output in the issue tooA repair checks the smallest thing that answers the failure
A CI failure names what broke. When Orgabot dispatches a repair, the brief tells the worker the narrowest check that would answer that failure, derived from the structured findings:
| Failure | Repair check |
|---|---|
| a compile error | the project's typecheck |
| a test failure naming test files | that test command, on those files, when it takes file arguments |
| a test failure naming no test file | the whole test command |
| a lint failure | the linter, on the files it named |
| a dependency advisory, or a setup failure | nothing smaller; the configured profile |
Three limits are built into the shape of this, not left to good intentions.
Paths are appended only to runners that take paths. cargo test tests/foo.rs is a substring filter on test names, not a file list: it matches nothing, runs zero tests, and exits 0. A green check over an empty scope is the worst thing this could produce, so the narrowing is allowed only for runners known to accept file arguments (vitest, jest, mocha, pytest, eslint and similar). A trailing -- does not make a command eligible on its own: it says arguments will be forwarded, not what they are forwarded to, and a project whose npm test -- script runs cargo test would be right back where it started. Everything else runs whole, which is what every project got before this existed. The same rule applies when the failure list was truncated: if CI named more failures than Orgabot shows, nothing is narrowed, because re-running only the shown ones and reading green would say the repair worked while the rest still stand.
It only ever narrows a command the project already configured. Orgabot does not invent a typecheck for a project that has none; a project that configures nothing narrowable simply gets no suggestion, which is what every project got before this existed.
A repair check is never a ship gate. It answers "did this repair address the observed failure?". The authoritative gate answers "is this commit acceptable to ship?" - a different question, with different evidence - and it runs afterwards regardless of what the repair check showed. The brief says so in as many words, because a repair round that reports "validation passed" and nothing else is exactly how a targeted check starts being read as permission to stop.
The suggestion is advisory throughout: if it cannot be derived, the brief is byte-for-byte what it was before, and nothing about the repair loop changes.
Rules the merge path will not bend
- PRs open as drafts; an auto-merge path promotes a draft to ready in the shared merge path before merging.
- The final merge respects the project's autonomy and approval policy.
- When a verify command is configured, a gate passes only on exit-code evidence, and a producer cannot waive its own gate. An unset verify is an explicit skip, not a fabricated full-suite pass.
- A review with a coverage gap is not a mergeable approval.