Skip to content

Orgabot is in private alpha. Request access, or join our Discord.

← All documentation
Using Orgabot

Shipping and review

How a change becomes a merged pull request: the independent review loop, PR-state classification, CI and conflict remediation, and the rules the merge path will not bend.

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 to

orgabot ship also takes --no-review, --no-ci, --no-merge, and --review-max N.

orgabot ship <project> with no --branch picks 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 by orgabot pending list), or ship them all with --all.

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.

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.

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. 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. 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-coverage is 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 N is 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 explicit orgabot stop ends 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.
  • 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 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.

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.

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.

`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. 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. 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. 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 that is itself an epic gets its own run rather than a mission, and the outer run parks on it until it finishes. 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; while a child mission is in flight the run is only re-checked about once a minute, since nothing can change until that mission finishes.

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 and role, 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. The decision is never made from the words in your instruction, and never hard-coded by the surface you pressed.

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 that exact primitive: the same normalization, the same capability routing, the same Mission Record. 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, /fix-epic resolves it through the provider and takes the type the provider reports, so an epic whose children are native sub-issues rather than #N lines in its body reaches epic execution here even where the dashboard's label-and-shape reading would not. Because the router still decides, /fix-epic on a reference that is not epic-shaped launches it correctly as an ordinary role-owned mission instead of forcing epic execution, and 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.

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-repository owner/repo#N, or a GitHub issue/PR URL copied out of quoted prose is rewritten to a plain issue 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-N looks inert but is not: GitHub lists it alongside #N as an issue reference syntax, so Orgabot does not use it.) The one Closes #N / Refs #N footer 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 @handle in either loses its sigil and reads as at-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.

StateWhat Orgabot does
UNKNOWNMergeability is computed asynchronously by GitHub, so a fresh PR often reports this. Orgabot polls with backoff instead of bailing.
BEHINDUpdates the branch against its base and re-checks.
CONFLICTINGMerges the base in; commits and pushes the resolution when the conflicts are unambiguous.
CHECKS_PENDINGPolls 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_REQUESTEDRoutes 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 branch or the 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 round that landed no commit). 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.
  • 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 1 plus 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 today

It resolves to:

PhaseAuthorityProfile
post_implementlocallight
post_repairlocallight
pre_shipCI-

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_ci is 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, once

The 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 too

A 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:

FailureRepair check
a compile errorthe project's typecheck
a test failure naming test filesthat test command, on those files, when it takes file arguments
a test failure naming no test filethe whole test command
a lint failurethe linter, on the files it named
a dependency advisory, or a setup failurenothing 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.