Skip to content

Agent: ship

The coding → review → fix loop to LGTM as one pipeline: agent:ship implements a task, gets the PR reviewed, hands the findings back, and repeats until the review approves — one dispatch, one card, one run record, with every side effect (branch create, PR open/edit, review post) executed by the bot process from typed artifacts.

  • Code: src/core/dispatch/ship.ts (the ship branch: the preflight, the one run record and card, the round loop over runShipPipeline, the report — over ShipDeps), src/core/dispatcher.ts (the fork into the branch), src/core/shipPipeline.ts (the caps, the interrupted note, the round header, and runShipPipeline — the round loop and its endings), src/core/ship/ (the pipeline's stages as files: preflight.ts — the entry checks, shipTaskText, shipBranchName; codingChild.ts — one coding round, round 0 and every fix round, with the branch contract and the fix turn; reviewChild.ts — one pinned-head review round with the review turn; childRound.ts — what the child rounds share), src/core/reviewRound.ts + src/core/codingPrPostStep.ts (the per-round units), src/core/reviewVerdict.ts (findings), src/tools/workspace.ts (submit_dispositions), src/agents/registry.ts (AGENTS["ship"]), src/config.ts (ship caps block), src/config/validate.ts (validateShip)
  • Docs: pr-description.md (the PR gate ship builds on), agent-coding.md, agent-review.md, resident-repos.md
  • Budgets: child rounds run on their own agent budgets (coding 60 turns/45 min, review 30 turns/25 min) clipped to the pipeline's remaining wall clock; pipeline caps ship.maxRounds (default 3) and ship.maxMinutes (default 120) resolve through the standard config layers — the wall clock is authoritative and whichever cap hits first ends the loop

Behavior

  1. A directive, never a registry command. agent:ship in <owner>/<repo>: <task> resolves through parseDirectives/AGENTS["ship"] (resources: { repo: "required" }, toolset: "full") and forks inside dispatch() after agent resolution — a CommandRegistry handler may never start an agent run (AGENTS.md invariant 3). Slack and CLI only: HTTP /ingress and the MCP dispatch tool refuse agent:ship with a pointer to the run page — both adapters are single-shot request/response and cannot hold a pipeline-length connection. Thread follow-ups are sticky like every agent.
  2. Compound permission gate. Running ship requires canRunAgent for ship AND coding AND review, plus the repo allowlist for the target repo; the ship branch forks after the dispatcher's repo gate so canUseRepo always runs. Child rounds never re-enter dispatch() — without the compound gate a user denied coding (or the repo) would gain push+PR capability through ship. The refusal names the missing grant.
  3. Ship owns the pipeline branch. The resident binds one ref per thread at first attach and ignores later hints (resident-repos.md item 16); a thread bound to the base branch would hand every review round the base tree. Ship names the branch itself, creates it on origin from the PR base via the GitHub REST API BEFORE the first attach (createBranchRef: the base tip's sha → refs/heads/<branch>; a 422 already-exists is success, so a restarted pipeline reuses its own deterministic branch) — the resident refuses to bind a ref origin does not have — and binds the thread to it at round 0's attach; the round-0 coding child is told it is already on that branch. A creation failure (or an unknown base) aborts with the reason before any round starts. Every coding round's attach must land on the pipeline branch — a thread already bound to another ref refuses before any model call, naming both refs — and every review round's attach must land at the pinned head (the extracted attach-head guard).
  4. Round 0 = the PR gate end to end. The coding child implements, pushes, and submits its PrDescription; the pipeline reuses the coding PR post-step (pr-description.md item 5) so ship holds {repo, prNumber, headSha} from code. Round-0 terminals other than "PR opened" — clarifying question, budget write-up, no description, unproven push — end the pipeline with a report naming the terminal; ship never retries a child round. The question passthrough keeps the thread alive: the user answers and re-enters.
  5. Review rounds run on a pinned head through the extracted units. Head pinned before dispatch (readonly attach, same threadKey), the run submits the structured verdict with findings, the reviewed-head gate + repush-void machinery apply unchanged, and the review posts pinned to the head it read. A verdict always covers the full diff against base — the re-review-delta skill narrows reading, never verdict scope. No verdict = abort: a review child that ends without submit_verdict (budget, refusal, stop) ends the pipeline with a report naming that terminal — it is never converted into a request_changes the reviewer did not make.
  6. Findings and dispositions are typed artifacts. ReviewVerdict.findings[]: { id, severity: blocking|major|minor|nit, file, line?, title }, validated fail-closed per finding; buildReviewPostBody renders them under the verdict line, and the body starts with the exact LGTM: token only for approve — an approve carrying a blocking finding is downgraded to request_changes in parseVerdictInput, so the auto-approve workflow can never fire over a self-declared blocking defect. Fix rounds record one disposition per finding through submit_dispositions (fixed|declined + note; unknown ids rejected by name). Dispositions are kept per round: finding ids are only unique within one review round, so a later round reusing an id for a new finding inherits nothing, while a finding carried forward unchanged (same id, severity, file, title) keeps its recorded disposition. Outside a ship fix round the tool has no sink and answers with an honest no-op ("dispositions were not recorded"), never a false "recorded" ack.
  7. Fix rounds address everything and repush. The findings payload rides the synthesized user turn (the address-review-findings skill carries the craft): every severity including nits gets a disposition, commits are squashed coherent, the description is resubmitted (the bot re-renders and edits the PR at the new head), and the branch repushed. A coding round — round 0 or a fix round — that pushed the pipeline branch onto an open PR without resubmitting gets the same bounded description turn a plain coding run gets (pr-description.md item 5), on a copy of the round's messages while its workspace is still attached, before the round's post-step; only a turn that still submits nothing leaves the post-step's warning. The fix round's PR post-step note reaches the thread exactly like round 0's, and a fix round that opened a NEW PR (the old one closed out from under the pipeline) hands its number to every later round and report. A fix round that ends with the branch still at the previously reviewed head repushed nothing — the pipeline aborts with that reason (plus the post-step's note) instead of burning a review round on the same diff, with ONE exception: a round that declined EVERY finding on the record changes no code on purpose, and the re-review still runs over the same head to verify those arguments and possibly concede (the decline path's designed resolution — maxRounds still bounds a decline stalemate). Rounds are strictly serial — the orchestrator awaits each child before dispatching the next, and checks the run's RunControl between rounds, so an operator stop short-circuits to the final report; the stop is honored between outcomes, never over one: a review verdict that already posted settles first — an approve that posted is merge-ready, stop or no stop, and a stopped report names a posted changes-requested review.
  8. Caps are ceilings, honestly reported. Each child dispatches with its budget clipped to the remaining pipeline time; a round starts only when the reservation check passes. Worst-case rounds exceed the 120-min default by design (three full worst-case rounds ≈ 255 min): the wall clock, not maxRounds, ends most worst-case pipelines, while typical rounds run far below their ceilings. A cap report distinguishes declined findings (disposition recorded) from unaddressed ones (no disposition), computed against the LAST review round's findings with only the dispositions recorded for them — a carried-forward finding keeps its disposition, a reused id inherits nothing.
  9. LGTM → merge-ready, never merged. Merge-ready stands on the POSTED approval: runReviewPostStep returns a typed outcome, and an approving verdict whose post failed or was refused by the reviewed-head guard ends the pipeline with an honest report naming the reason — the PR carries no approving review; re-run ship in the thread with the PR URL to retry. On a posted approve the pipeline re-checks the PR and reports merge-ready: PR link, rounds used, declined findings, and the pending human merge as the remaining gate; a re-check that cannot fetch the PR says its state could not be re-verified — never "closed". Ship's bot-process GitHub writes are exactly the round-0 branch create, the PR open/edit, and the pinned review post — no merge endpoint is reachable from any ship code path, and the coding prompts + fix skill carry never-merge/never-approve. Because the approving verdict's LGTM: line triggers the auto-approve workflow in a repository that has opted in, ship refuses a target repo with auto-merge enabled up front.
  10. Entry checks make restarts safe. Ship never opens a duplicate PR (open-or-edit by head branch). A ship invocation resumes at review — skipping round 0 — only when ALL hold: a user turn names the PR (the thread→PR inference reads user turns only), it is open, authored by this bot's own GitHub identity (the App's bot user — resolveGithubIdentity, resolved from GitHub once per process and matched by login AND immutable id; the code names no bot), same-repo head, and the invocation carries no new task text. An identity that cannot be resolved refuses fail-closed: an open PR's authorship cannot be judged. A PR reference binds the entry checks only when the invocation carries no other task text (a resume request) or the PR is ship's own; a PR number quoted as evidence inside new task text does not bind — it stays in the task as context and round 0 starts from the repo's default branch (or a user-named on <ref>), never from the quoted PR's head branch. This holds even when the cited PR's facts cannot be fetched: a PR named in the CURRENT message (repoContext.prFromMessage) beside new task text falls through to round 0 rather than refusing on the transient failure, and the round-0 base drops any PR-derived ref (repoContext.refFromPr, flagged at the resolver) so the failed fetch cannot leak the stranger's head branch. Kept fail-closed on a fetch failure: an INHERITED thread PR (not in-message), and a BARE in-message reference with no task text (a resume attempt must verify the PR first). A new task over ship's own still-open thread PR is refused naming the PR; a bare reference to a human-authored PR is refused as not ship's to drive. Terminal reports tell the user to include the PR URL when re-issuing. No mid-loop round state persists — recreatability, not resumption (AGENTS.md invariant 6). When the bot dies under a pipeline, the next generation closes its run interrupted and tells the thread (run-history item 36): the PR it had opened, if any, and the exact re-issue that continues the loop — agent:ship with only the PR URL (this item's resume-at-review), or with the task when no PR existed (round 0 again on the same deterministic branch); the same note closes its card. Nothing restarts a pipeline unattended.
  11. Resident-only v1. No resident worktree → a plain report, never per-round cold clones. Each round attaches its own executor with the child agent's toolset (readonly review attach / writable coding attach — the mode-switch wipe is the accepted cost, bounded by clipped budgets).
  12. Rounds are legible. A typed ship_round run event (index, agent, outcome) marks every boundary on the one stream — per-round cost is derivable by slicing model.turn spans between them, and every round is also a ship.round span (index, agent attrs) with the child run's run.agent under it (tracing.md item 17) — and the card carries an orchestrator-owned round header (its own variable composed into the frame, so a child's update_status cannot erase it). ship joins NO_REFLECT_AGENTS: its report is per-PR findings ephemera. Endings are truthful: the outcome statuses distinguish completed / aborted / capped / stopped, and the card closes ✅ with a checked-off checklist only for a completed pipeline — an abort or cap closes ⚠️ over the un-rewritten checklist, stops keep ⏹/⛔ — while a final reply that throws writes the run record failed (the main path's invariant), never a completed record the thread never saw.

Roadmap (gaps)

  • [gap] Separate child RunRecords with a parent/pipeline id — requires the node-free run-record contract shared with deploy/cloudflare-memory/worker.ts; round attribution rides ship_round events until then.
  • [gap] Ship over HTTP/MCP — needs a job-shaped ingress (accept, return the run link, run detached), not a held socket.
  • [gap] Cold-sandbox ship path (per-round clones); v1 refuses without a resident.
  • [gap] Warm cross-mode worktrees — today every readonly↔writable round switch wipes and reclones (resident-repos.md item 50).
  • [gap] Per-round memory reflection — NO_REFLECT_AGENTS drops the coding rounds' learnings along with the report's ephemera; revisit with child records.

Validation criteria

CriterionProof
AGENTS["ship"]: repo required, full toolset, default budgets; directive resolves on Slack/CLI[unit] src/agents/registry.test.ts::ship agent…
HTTP/MCP refuse agent:ship with a run-page pointer, no pipeline[unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::channel guard…
Compound gate: allowed ship but denied coding → refused naming coding, no child run; denied repo → refused, no child run[unit] ::permission: user allowed ship but not coding…, ::allowed all three agents but denied the target repo…
Auto-merge repo refused before round 0[unit] ::auto-merge repo → refused before round 0
LGTM round 1: coding → PR → approve → merge-ready reply (PR URL, rounds, pending human merge)[unit] ::LGTM round 1…
Findings round trip: request_changes → fix child gets the payload verbatim → re-review → approve; dispositions in the final report[unit] ::findings round trip…
Approve + failed post → honest report, no merge-ready claim; approve + guard-refused post → same; the post step's typed outcome[unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::approve whose post FAILED…, ::approve whose post was REFUSED…, src/core/reviewRound.test.ts::runReviewPostStep… (posted/skip/failure outcome rows)
Merge-ready re-check that cannot fetch the PR → "could not be re-verified", never "no longer open"[unit] ::merge-ready re-check that cannot fetch…
No verdict from a review child → abort report naming the terminal, no fix round[unit] ::no verdict from review child…
maxRounds cap → report splits declined vs unaddressed; a later round reusing a finding id inherits nothing (listed unaddressed)[unit] ::maxRounds cap…, ::a later round reusing a finding id…
Child budgets clipped to remaining wall clock; reservation check refuses a round before the deadline passes[unit] ::wall-clock: a child is dispatched with clipped maxMinutes…, ::reservation check…
Branch binding: round 0 creates the pipeline branch from base on origin BEFORE the first attach (422 already-exists tolerated; creation failure → abort, no attach; a resume never creates); every round's attach sha equals the pinned head; a thread bound to another ref → coding-round refusal naming both refs[unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::branch binding…, ::fresh pipeline: the bot creates…, ::branch creation fails…, ::a thread already bound to another ref…, src/execution/githubPulls.test.ts::createBranchRef…
One coding round on its own (the stage): no resident → a plain result and no model turn; a thread bound elsewhere → refusal naming both refs; the child runs on a clipped copy of its def under the branch contract and the PR opens from the typed description (redacted pr_description, pr_opened published); a round that left the pipeline branch → no PR write; a fix round's LAST dispositions and a hard stop ride back typed[unit] src/core/ship/codingChild.test.ts::runShipCodingChild…::*
One review round on its own (the stage): an unknown PR head → the pre-flight's refusal before any attach; no resident → a plain result and no model turn; the round attaches at the pinned head, runs a clipped copy of the review def on the synthesized turn (a later round's turn carries exactly the prior findings and dispositions it was handed) and hands back the verdict, the reviewed head and the POSTED outcome; a verdict-less child posts its prose with no LGTM line; a hard stop settles and posts nothing[unit] src/core/ship/reviewChild.test.ts::runShipReviewChild…::*
Fix round with no new head → abort carrying the post-step's reason, no second review; a fix round's NEW PR adopted for later rounds + report[unit] ::a fix round that repushed nothing…, ::a fix round that opened a NEW PR…
Entry checks: bot-authored user-named open PR + no new task → resume at review, no create; new task over open PR → refusal naming it; human-authored PR → refusal[unit] src/execution/githubIdentity.test.ts::resolveGithubIdentity::*; src/core/dispatcher.test.ts::agent:ship (pipeline)::thread with user-named, bot-authored open PR…, ::thread PR open + new task…, ::thread PR authored by a human…, ::the bot's own GitHub identity unresolvable…
Entry checks: a foreign PR cited in new task text falls through to round 0 off the default branch — even when its facts fetch fails — and drops the PR-derived ref (refFromPr); an INHERITED unreachable PR or a BARE in-message reference stays fail-closed; the resolver flags in-message PRs (prFromMessage) and PR-head refs (refFromPr)[unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::new task text citing a human-authored open PR does NOT bind it…, ::in-message cited PR + new task text + FAILING prFacts fetch…, ::inherited unreachable PR (prUnpostable) + new task text…, ::in-message cited PR + NO task text + failing prFacts fetch…, src/core/repoContext.test.ts::…PR-source flags for ship…::…prFromMessage…
Resident unavailable → plain report, no cold clone[unit] ::resident attach fails…
Operator soft stop between rounds → no new round, stopped report; a stop during a round settles a posted verdict first (approve → merge-ready; request_changes → stopped report naming the posted review)[unit] ::operator soft stop…, ::a soft stop flagged during an approving round…, ::a soft stop flagged during a changes-requested round…
Endings close the card truthfully (completed → ✅ checked-off; abort/cap → ⚠️ un-rewritten; stops ⏹/⛔) and a thrown final reply records failed[unit] ::card close is truthful…, ::maxRounds cap… (⚠️ close), ::a final reply that throws…
No merge path: no PUT …/merge seen across every pipeline scenario[unit] ::no merge path…
Verdict findings validated fail-closed; approve+blocking downgraded; LGTM token contract holds with findings present[unit] src/core/reviewVerdict.test.ts (findings + downgrade + body-shape rows)
submit_dispositions: valid set accepted, unknown id named, second call wins, full toolset only; no sink (no ship fix round) → honest no-op, never a false "recorded" ack[unit] src/tools/workspace.test.ts::submit_dispositions tool
ship_round events in order with typed index/agent/outcome; round header survives child update_status[unit] src/core/dispatcher.test.ts::agent:ship (pipeline)::round events…, ::round header…
Skills scoped: address-review-findings → coding only, re-review-delta → review only; manifest check green[unit] skill-store scoping test; npm run skills:check
Machinery run: agent:ship on a throwaway task reaches LGTM in ≤2 rounds — thread link, PR, round events, reviews pinned per head[agent] agent:ship in <owner>/<repo>: <throwaway task> in a thread → the card closes ✅ with a merge-ready reply naming the PR; the run page shows one ship_round boundary per round and one review per head, pinned to it.
Cap run: maxRounds=1 for the test thread → cap report with the declined/unaddressed split[agent] set ship.maxRounds=1 for the test channel, run a task the reviewer will not approve on the first pass → the card closes ⚠️ with the cap report listing declined and unaddressed findings separately.
Judgment run: a real-repo task — observed round count recorded; a human records agreement/disagreement with the approving verdict (human-gated)[agent] run agent:ship on a real task; record the round count from the ship_round events and, once approved, have a human read the PR and record whether they agree with the approving verdict.