Land vs ship
Merge a mission locally, or push it through the tribunal pipeline into a real pull request.
A finished mission leaves its camp in one of two ways. Both are explicit, and neither is silent.
vx land | vx ship | |
|---|---|---|
| Result | local fast-forward of your checkout | a real GitHub pull request |
| Review pipeline | none; you reviewed it | lint, tests, an adversarial review, a docs check (lint and tests need a Go or Node project, docs only Go) |
| Needs | a clean checkout | gh installed |
| Leaves the machine | no | yes, it pushes to origin |
By default the commander asks before landing. With yolo mode on for the project, it lands a finished, verified mission without asking. Yolo never covers vx ship.
A shipped soldier waits on purpose
After a successful vx ship the soldier's pane and camp stay open until you merge the pull request. That is by design, not a stuck soldier. See Why a shipped soldier stays open below.
Land
vx land <task-id>Fast-forwards your project's own checkout to the mission's branch, then strikes the camp and closes its pane. It refuses, leaving everything untouched, if your checkout is dirty or the branch has diverged from the base. It never forces or rebases.
Ship
vx ship <task-id>Runs, in order, inside the mission's camp, stopping at the first failure without pushing anything:
- lint -
go vet ./...for a Go project,npm run lint --if-presentfor a Node one; - tests -
go test ./...ornpm run test --if-present; - review - an adversarial review of the diff by a fresh session of the agent the mission ran on (see below);
- docs - for Go projects, new exported declarations must carry a doc comment. In any other project this step is skipped and reports as passed.
The adversarial review
The review runs in a brand-new headless session of the harness the mission ran on, Claude Code by default (never a resumed one), that has none of the author's context. It is given the branch, the base and target commits and the list of changed files vexillum computed - not the diff. It reads the diff itself with git, assumes the change is wrong and tries to break it: correctness, edge cases, error handling, concurrency, security and authorization, regressions and the quality of new tests. A finding needs a concrete failure scenario from the change's intended use, and each defect class is reported once with all its sibling sites. On Claude Code it runs with sonnet at high effort; on Pi and OpenCode it runs with the model and thinking level the mission itself ran with, or the harness's default when the profile set none. It does not run the tests (the previous step did).
When the mission has a prompt, every component the change introduced is also judged against that statement plus the instructions the general gave afterward, through vx prompt and vx decide. Those are recorded on the task as amendments (with a timestamp and the command they came from, in vx status --json) and listed after the prompt, in order, so a component you asked for later is not reported as unrequested. Only those two commands write amendments, into the task's state file and never into a file in the camp, so a soldier cannot widen its own mandate. Whatever neither the prompt nor an amendment required is reported as a warning whose remedy is removing it. A mission without a prompt skips this pass, and ship says so.
The reviewer returns JSON: findings (file, line, severity error/warning/info, action ask-user/auto-fix/no-op, description, failure scenario, sibling sites), the reviewed_paths it actually read, and a risk level, plus the optional pr_title and pr_description that become the pull request text (invalid ones never fail the review, see below). If the JSON does not validate, the review is retried (up to two more times). Then:
- any finding of severity error or warning blocks the ship, including
ask-userones, which need your decision; info findings do not block; - a changed file missing from
reviewed_pathscounts as unreviewed and fails the step; - no valid JSON after the retries, or a timeout, fails the step. Neither is ever a pass.
Findings are printed in the output of vx ship. If the ship passes, the info findings are added to the pull request body under Tribunal notes.
Review, fix, review (opt-in)
vx ship <task-id> --fix [--max-rounds 2] [--timeout 20m]Without --fix, a blocking review only rejects the ship and prints the findings. With --fix, findings marked auto-fix go to a headless fixer in the camp, which fixes the invariant at every sibling site (and prefers removing an unrequired component to hardening it). vexillum commits its work, runs lint and tests again, and a new reviewer reviews again, told that the fixer's commits are unreviewed code and must meet the same standard. Whoever prescribed a fix never certifies it.
ask-user findings are never auto-fixed: if any blocking finding needs a human, ship rejects without a fix round. --max-rounds caps the fix rounds (default 2); running out without a passing review rejects the ship. --timeout caps each single reviewer or fixer run (default 20 minutes).
With neither go.mod nor package.json, lint and tests pass with a note that nothing was detected: nothing was run, so a pass there says nothing about the code. A Node project without a lint or test script passes the same way, because of --if-present. It is synchronous: when ship returns, the whole pipeline has settled. Only after every step passes does it push the camp branch to origin and run gh pr create. No agent decides whether or when that happens: it is the one action with an irreversible effect outside the machine.
A mission in done or shipped can be shipped again to push follow-ups onto the same PR; it re-runs the full pipeline first. Whenever a pull request for the branch already exists, vx ship reuses it instead of opening a second one: it pushes to the same branch and refreshes the pull request's title and description from the new review (see below). A pull request that is already merged or closed cannot take more commits, so vx ship refuses before running anything.
A rebased branch
If the soldier rebased or amended a branch that vx ship already pushed, the next push is rejected as non-fast-forward. vx ship records the tip of every push it makes on the task (last_pushed_sha), and on that rejection it reads the tip origin holds for the branch. When that tip is exactly the recorded one, the rewrite is vexillum's own work, so it retries once with a lease pinned to that commit:
git push --force-with-lease=<branch>:<recorded sha> origin <branch>The lease names the mission branch only, never the base branch or any other ref, and git refuses it if origin moved in the meantime. vx ship prints one line (rewrote the PR branch <branch> on origin (force-with-lease) from <old> to <new>), refreshes the pull request's title and description and prints its URL.
When origin's tip is anything else, because somebody else pushed to the branch, or when no push is recorded, vx ship never forces. It fails and says what origin holds and how to decide: run git fetch origin <branch> in the camp and inspect it, or authorize one manual lease push with the origin SHA it printed, then run vx ship <task-id> again.
The pull request text
The pull request is public, and its text needs no manual step. The adversarial review reads the whole diff and the commits with no author context, and also returns a conventional-commit style pr_title (about 72 characters) and a short factual pr_description (what changed and why, 3 to 8 lines of plain markdown), written from the diff and never from the mission prompt. vx ship uses them as the title and the What section. The rest of the body is built from facts: a Changes line (files changed, insertions, deletions and the top-level areas touched), a Verification line (the tribunal steps that passed, the review rounds and the fix rounds), the review's info findings under Tribunal notes, and a one-line footer naming the mission.
When the reviewer omits those fields or returns invalid ones (a title that is not one line in conventional-commit style, a description with headings, or any text that fails the sanitizer), vx ship says so and falls back, per field, to text derived from the branch: the title is the subject of the branch's only commit, or of its newest feat, fix or docs commit, or of its first commit, cut to about 72 characters, and What is the commit subjects as a bullet list.
Everything that comes from the camp or the reviewer is sanitized before publishing: lines with an absolute home path (/Users/..., /home/...), a localhost port or something that looks like a secret are dropped, as is any line quoting the mission prompt. Reviewer text that fails the check is replaced by the derived text rather than published half-cut, and vx ship reports how many lines it dropped from anything else.
Overrides are optional:
vx ship <task-id> --title "feat: concise title" --body-file description.md--title replaces the title. --body "<text>" or --body-file <path> (- reads standard input; the two cannot be combined) replaces the What section; Changes, Verification, Tribunal notes and the footer stay. A --title that fails the safety check is refused before the tribunal runs. When the pull request already exists, the overrides apply to the refresh; without them the refreshed text comes from the new review, so a text edited by hand on GitHub is replaced.
Landing a shipped mission
vx land <task-id>For a shipped task, land merges the real pull request instead. It requires gh and refuses unless the PR is open, not a draft, mergeable and every check is green; the merge is bound to the exact head it just verified. It does not strike the camp: the branch is still in flight until the PR is actually merged.
Why a shipped soldier stays open
After a successful vx ship the mission's status is shipped: the branch is pushed and a real pull request is open, but the work is not landed locally, so vx ship does not strike the camp. The soldier's pane and its camp (worktree) stay open on purpose until the pull request is merged. It is not stuck or stranded: it appears as shipped in vx status, and under Shipped on the muster board.
The reason is practical. If CI, a reviewer or you ask for changes on the pull request, the same soldier still has the full context and the branch mounted. vx prompt accepts a shipped task like any finished one:
vx prompt <task-id> "address the review comments"The text goes to the soldier's pane and is recorded as an amendment, like any other vx prompt. The task goes to running while the soldier works, and when it settles it is done again, not shipped, because the new commits are not on the pull request yet. The commander gets one notice for that settle. Then run vx ship <task-id> again: the full pipeline runs, the commits are pushed to the same branch, and since a pull request for the branch already exists it is reused and its title and description refreshed, never a second one.
How it ends
Merge the pull request, either on GitHub or with vx land <task-id>. Then, from your project's checkout on the base branch:
git pull
vx strike <task-id>vx strike never fetches. It checks the camp against your local base branch only: the worktree must have no uncommitted changes, and the camp's commits must already be on that branch. Right after a merge on GitHub, and before the pull, your local base does not have them yet, so vx strike refuses and leaves the camp and pane untouched. The refusal says what to do: merge the pull request, run git pull on the base branch, then retry. After git pull it succeeds, also for a squash or rebase merge: besides the plain ancestor check, it accepts a camp whose content the base already contains under different commit SHAs. It then returns the camp to the pool and closes the pane.
When GitHub says it merged
For a shipped task, and only then, vx strike also asks gh whether the pull request for the camp's branch is MERGED. This is best effort and never required: it needs gh installed and logged in, and without it strike relies on the local checks alone. A task that is not shipped makes no network call.
A merged pull request counts as landed once its merge commit is reachable from your local base branch, and GitHub's word is trusted over the content check. That matters because the content check can refuse forever after a remote merge when later commits on the base touched the same lines. If the merge commit is not on the local base yet, strike tells you to git pull. A camp that holds commits the merged pull request never carried is still refused.
Branch cleanup after a strike
After a successful strike, vx strike also prunes. It deletes the task's local branch vexillum/<task-id> with git branch -d semantics, never -D, when the branch is an ancestor of the base branch. A shipped task whose pull request gh reports as merged, with the merge commit on your local base, also counts, even though a squash or rebase merge is not an ancestor. It never deletes a branch checked out in another worktree. When none of that holds, the branch is kept and one line says why and what you can do, for example git branch -D once you are sure. It also runs git worktree prune on the project repo so stale camp registrations do not pile up, and prints what it pruned in one short line. --discard follows the same rules, so an unlanded branch survives it.
Striking anyway with --discard
vx strike <task-id> --discard--discard strikes the camp even when that check fails or the worktree has uncommitted changes, and it throws that work away. It prints exactly what it discarded: each unlanded commit (hash and subject) and each uncommitted change, and it resets the worktree so the slot can be reused. The commits stay reachable from the camp's branch until the slot is reused.
It is for one case: the camp's work is already on the base, or is abandoned, and the general confirmed it. The commander never uses it without the general's approval. A refusal that says to merge the pull request and pull is answered by merging and pulling, not by --discard.