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 overrunShipPipeline, the report — overShipDeps),src/core/dispatcher.ts(the fork into the branch),src/core/shipPipeline.ts(the caps, the interrupted note, the round header, andrunShipPipeline— 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(shipcaps 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) andship.maxMinutes(default 120) resolve through the standard config layers — the wall clock is authoritative and whichever cap hits first ends the loop
Behavior
- A directive, never a registry command.
agent:ship in <owner>/<repo>: <task>resolves throughparseDirectives/AGENTS["ship"](resources: { repo: "required" },toolset: "full") and forks insidedispatch()after agent resolution — aCommandRegistryhandler may never start an agent run (AGENTS.md invariant 3). Slack and CLI only: HTTP/ingressand the MCP dispatch tool refuseagent:shipwith 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. - Compound permission gate. Running ship requires
canRunAgentforshipANDcodingANDreview, plus the repo allowlist for the target repo; the ship branch forks after the dispatcher's repo gate socanUseRepoalways runs. Child rounds never re-enterdispatch()— without the compound gate a user deniedcoding(or the repo) would gain push+PR capability through ship. The refusal names the missing grant. - 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). - 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. - 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-deltaskill narrows reading, never verdict scope. No verdict = abort: a review child that ends withoutsubmit_verdict(budget, refusal, stop) ends the pipeline with a report naming that terminal — it is never converted into arequest_changesthe reviewer did not make. - Findings and dispositions are typed artifacts.
ReviewVerdict.findings[]:{ id, severity: blocking|major|minor|nit, file, line?, title }, validated fail-closed per finding;buildReviewPostBodyrenders them under the verdict line, and the body starts with the exactLGTM:token only forapprove— anapprovecarrying ablockingfinding is downgraded torequest_changesinparseVerdictInput, so the auto-approve workflow can never fire over a self-declared blocking defect. Fix rounds record one disposition per finding throughsubmit_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. - Fix rounds address everything and repush. The findings payload rides the synthesized user turn (the
address-review-findingsskill 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 —maxRoundsstill bounds a decline stalemate). Rounds are strictly serial — the orchestrator awaits each child before dispatching the next, and checks the run'sRunControlbetween 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. - 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. - LGTM → merge-ready, never merged. Merge-ready stands on the POSTED approval:
runReviewPostStepreturns 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 postedapprovethe 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'sLGTM:line triggers the auto-approve workflow in a repository that has opted in, ship refuses a target repo with auto-merge enabled up front. - 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-namedon <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 runinterruptedand tells the thread (run-history item 36): the PR it had opened, if any, and the exact re-issue that continues the loop —agent:shipwith 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. - 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).
- Rounds are legible. A typed
ship_roundrun event (index, agent, outcome) marks every boundary on the one stream — per-round cost is derivable by slicingmodel.turnspans between them, and every round is also aship.roundspan (index,agentattrs) with the child run'srun.agentunder it (tracing.md item 17) — and the card carries an orchestrator-owned round header (its own variable composed into the frame, so a child'supdate_statuscannot erase it).shipjoinsNO_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 recordfailed(the main path's invariant), never a completed record the thread never saw.
Roadmap (gaps)
[gap]Separate childRunRecords with a parent/pipeline id — requires the node-free run-record contract shared withdeploy/cloudflare-memory/worker.ts; round attribution ridesship_roundevents 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_AGENTSdrops the coding rounds' learnings along with the report's ephemera; revisit with child records.
Validation criteria
| Criterion | Proof |
|---|---|
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. |