- name
- autoship
- description
- Orchestrate a bounded round of automated issue processing: reclaim orphaned in-progress issues, discover eligible `autoship:ready` issues, and invoke `/ship` sequentially for each — stopping at cost or count caps and surfacing blocked items without halting the round. Requires `--max-issues` and `--max-cost-usd`. Use when you want a self-contained automated delivery round driven from the issue tracker.
- argument-hint
- --max-issues N --max-cost-usd N [--dry-run] [--label LABEL] [--max-batch-size N]
- user-invocable
- true
- effort
- medium
- allowed-tools
- Read, Write, Glob, Grep, Task, Bash(python3 *), Bash(gh *), Bash(command -v gh), Skill(ship *), Skill(cost-report *), mcp__github__search_issues, mcp__github__issue_read, mcp__github__search_pull_requests, mcp__github__issue_write, mcp__github__add_issue_comment
# Autoship
Role: orchestrator. This skill runs one bounded round of automated issue
dispatch. It does not implement code, review, or merge — it sequences the
existing `/ship` pipeline per dispatch unit and logs each outcome.
You have been invoked with the `/autoship` command.
## Orchestrator constraints
1. **Never start without both caps.** Refuse immediately if `--max-issues` or
`--max-cost-usd` is missing from `$ARGUMENTS`.
2. **Sequential only.** Process one issue at a time. Do not launch concurrent
`/ship` invocations.
3. **Delegate every phase.** Call the owning scripts and skills; do not
re-implement discovery, reclaim, shipping, or cost reading here.
4. **No scheduling logic.** This skill runs once per invocation. Timer or
recurring execution is the caller's responsibility.
5. **Dry-run is preview only.** When `--dry-run` is given, run reclaim and
discovery in preview mode; never label, comment, invoke `/ship`, or write
to the round log.
## Parse Arguments
Arguments: $ARGUMENTS
Required:
- `--max-issues N` — maximum number of issues to process this round (positive
integer).
- `--max-cost-usd N` — budget ceiling in USD for the entire round (positive
number).
Optional:
- `--dry-run` — preview mode: report what would run without side effects.
- `--label LABEL` — override the eligibility label (default: `autoship:ready`).
- `--max-batch-size N` — override `autoship_group.py`'s per-batch member cap
(default: 5, matching the script's own default).
If either required argument is absent, print this message and stop:
```
autoship: --max-issues and --max-cost-usd are both required.
Usage: /autoship --max-issues N --max-cost-usd N [--dry-run] [--label LABEL] [--max-batch-size N]
```
**Cross-validate `--max-batch-size` against `--max-issues` (#2073).** A batch
larger than `--max-issues` can never be admitted into the queue — `Step 2`'s
`autoship_queue.py --max-issues <N>` defers a whole unit rather than
splitting it, so a batch sized above the round's own cap is permanently
undispatchable and every round that produces one repeats the same
`no_unit_fits_cap` early exit until an operator notices and reruns with
compatible caps. If `--max-batch-size` is given and its value is greater
than `--max-issues`, print this message and stop, without proceeding to
Step 1:
```
autoship: --max-batch-size <B> cannot exceed --max-issues <N>.
Usage: /autoship --max-issues N --max-cost-usd N [--dry-run] [--label LABEL] [--max-batch-size N]
```
## gh CLI availability (#1700)
Check once, before Step 1: `command -v gh`.
- **gh present** (the normal case — a local/CI session with the CLI
installed and authenticated): every step below runs exactly as written,
invoking `gh` directly (via `autoship_reclaim.py`/`autoship_group.py`'s
live fetch, and the raw `gh issue edit`/`gh issue comment` calls in Steps
3b/3d).
- **gh absent** (a Claude Code web/cloud session — GitHub access there is
provided only through the `mcp__github__*` tools, never a `gh` binary):
every step below that would otherwise shell out to `gh` instead uses the
MCP-tool path called out in that step. The scripts themselves never gain a
network code path of their own — they stay pure decision logic over
`--input-file` JSON (already true for `autoship_discover.py`'s read side
and, since #1700, `autoship_reclaim.py`'s write side too via
`--emit-actions-only`); only the data gathering and the actual GitHub
mutation move up to this skill, because MCP tools are only callable from
the agent context, never from inside a Python subprocess.
- **Known gap, gh-absent discovery only**: `autoship_group.py` (via the
shared `autoship_state.fetch_eligible_issues` eligibility filter it
calls into) needs two GraphQL-shaped fields (`subIssuesSummary`,
`closedByPullRequestsReferences`) that `gh issue list --json` computes
for free but that the REST-backed MCP tools don't return in one call.
The MCP-path instructions in Step 2 approximate them —
`mcp__github__issue_read` (method `get`) per candidate for the epic
check, and a `mcp__github__search_pull_requests` query for the
open-linked-PR check — and are deliberately conservative (treat an
ambiguous match as "has an open PR", i.e. skip it) since a false include
is worse than a false exclude for an autoship gate. This is real but
bounded: it only touches the small number of issues that already carry
the ready label, not the whole repo. Step 2 also documents a second,
narrower gh-absent gap specific to grouping itself — the
`blockedBy`/`blocking`/`parent` fields `autoship_group.py`'s dependency
and shared-parent signals need, which this skill's MCP toolset has no
call for at all.
- `/ship` (Step 3c) has its own separate `gh` dependency (`Bash(gh pr *)`,
`Bash(gh issue *)` in its own `allowed-tools`) that this fix does not
touch — a gh-absent round can reclaim/discover/label via MCP, but `/ship`
itself still needs `gh` to open the PR. Out of scope for #1700; file a
follow-up if full gh-less autoship end-to-end is wanted.
## Step 1 — Reclaim orphaned issues
Run the reclaim script to relabel any stale `autoship:in-progress` issues back
to `autoship:blocked` before discovery. This does not change `--max-issues`
accounting — `autoship_state.is_eligible` already excludes any issue carrying
`autoship:in-progress` or `autoship:blocked` regardless of whether reclaim has
run, so a stale in-progress issue is excluded from the eligible pool either
way. Reclaim's real purpose is unsticking issues orphaned by a crashed round
and routing them to human triage before they sit invisibly forever.
**gh present:**
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/autoship_reclaim.py" \
[--dry-run] # pass when --dry-run was given
```
**gh absent:**
1. Fetch open issues labeled `autoship:in-progress` via
`mcp__github__search_issues` (`query: "is:issue is:open label:autoship:in-progress"`,
`fields: ["number", "title", "labels", "updated_at"]`). `mcp__github__search_issues`
is a paginated search tool with its own default page cap — page through every
result page until exhausted, up to 500 issues, matching the gh-present path's
`--limit 500` above: without this, a repo with more than one page of stale
in-progress issues would silently have this step see only the first page,
reclaiming only part of the full eligible pool.
2. Build a JSON array matching `autoship_reclaim.py`'s `--input-file` schema
— one object per issue with `number`, `title`, `state: "OPEN"`, `labels`
(as `[{"name": "..."}, ...]`), and `labeled_at` (use `updated_at` from the
search result — the script's own live-fetch path falls back to
`updatedAt` the same way when it can't resolve the real timeline event, so
this is not a regression). Write it to a scratch file.
3. Run the script against that file, with `--emit-actions-only` (never
`--dry-run` and `--emit-actions-only` together unless `--dry-run` was
itself given — dry-run alone already previews correctly with no gh calls):
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/autoship_reclaim.py" \
--input-file <scratch-file> --emit-actions-only \
[--dry-run] # pass when --dry-run was given
```
4. In `--dry-run` mode, the script's `would-reclaim` preview lines are the
report — stop here, nothing to execute. Otherwise, the script prints one
JSON action per line (`{"number", "comment", "relabel_remove":
["autoship:in-progress", "autoship:batch-confirmed"], "relabel_add":
["autoship:blocked"]}`, exit 0) or, on a failure it detects itself (e.g.
a malformed input file), a `autoship_reclaim: ...` error on stderr with a
non-zero exit — treat that the same as today's "reclaim failure is
non-fatal" handling below. For each successfully-emitted action, execute
it directly: `mcp__github__add_issue_comment` with the action's `comment`,
then `mcp__github__issue_write` (method `update`, removing every label in
`relabel_remove` and adding every label in `relabel_add` via the `labels`
field — read the issue's current labels first, since `issue_write`'s
`labels` replaces the full set rather than diffing it).
Report how many issues were reclaimed (or would be reclaimed in dry-run). A
reclaim failure is non-fatal — log the error and continue to discovery.
## Step 2 — Discover eligible issues
Run the grouping/queueing pipeline to select and order the issues this round
will process. This is now **two separate commands with a scratch file in
between, not a single shell pipe** — Step 2b (agent-proposed grouping) and
Step 2c (block-and-comment) run between them, against that scratch file's
`ungrouped` array, before it ever reaches the second command.
**gh present:**
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/autoship_group.py" \
[--label "<label>"] [--max-batch-size "<max_batch_size>"] \
> <scratch-grouping.json>
```
**Resolving `confirmed_batch_members` (Step 4.3).** `has_batch_confirmed_override`
(`autoship_group.py`) needs, on every eligible candidate carrying
`autoship:batch-confirmed`, a `confirmed_batch_members` field — the most
recent `<!-- autoship-batch-members: ... -->` marker from that issue's own
comments, parsed into an int list — added to its JSON before grouping runs.
Resolve it now: run `gh issue view <n> --json comments` per candidate
carrying `autoship:batch-confirmed`, and extract the marker from the
returned comment bodies (most recent match wins).
**Author and value validation (security).** Issue comments are
attacker-influenceable on a public repo, so the marker must not be trusted
from just any commenter. Resolve the invoking identity concretely: run `gh
api user --jq .login` once per round to get the currently-authenticated
login. Then run the deterministic transform (#2072 — extracted so a future
round can't silently drift from the documented rule) per candidate carrying
`autoship:batch-confirmed`:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/autoship_proposals.py" parse-marker \
--comments-file <scratch-comments.json> \
--invoking-login "<login>"
```
It implements: only extract it from a comment posted by this skill's own
actor — e.g. filter `comments[].author.login` against the invoking bot/user
identity — before treating it as authoritative (if the `gh api user` call
above failed or returned nothing, the identity cannot be resolved — fail
closed: pass an empty/unmatchable `--invoking-login` so the marker reads as
absent, i.e. no `confirmed_batch_members` for that candidate, same as the
"value fails" outcome below). Then, once extracted, validate every parsed
value matches `^[0-9]+$` before merging it into `confirmed_batch_members` —
if any value fails, drop the whole marker (treat it as absent, i.e. no
`confirmed_batch_members` for that candidate) rather than merging a
partially-valid list. Its stdout is `{"confirmed_batch_members": [...] |
null}` — `null` is the "absent" outcome above.
The plain self-fetch
invocation above has no seam to receive this enrichment, so whenever at
least one eligible candidate carries `autoship:batch-confirmed` this round,
replace it with an explicit `--input-file` built from `gh issue list
--state open --label "<label>" --limit 500 --json
number,title,state,createdAt,labels,closedByPullRequestsReferences,subIssuesSummary,blockedBy,blocking,parent`
(the same fields the self-fetch would request, plus `--limit 500` — `gh
issue list` applies a default result cap, and without an explicit override
this command silently fails to fetch the full eligible pool it claims to)
with `confirmed_batch_members` merged onto the enriched subset:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/autoship_group.py" \
--input-file <enriched-scratch-file> \
[--label "<label>"] [--max-batch-size "<max_batch_size>"] \
> <scratch-grouping.json>
```
When no eligible candidate carries `autoship:batch-confirmed` this round,
the plain self-fetch invocation above is used unchanged.
Unlike Step 1's reclaim, a discovery failure is fatal for the round — abort
before running the second command below, and do not run it against a missing
or stale `<scratch-grouping.json>`. The actionable error is the
`autoship_group:`-prefixed line on stderr from this FIRST command; if
`autoship_queue.py` is run anyway despite that failure, it will report its
own unrelated "grouping output is not valid JSON" message, not the real
cause.
`autoship_group.py` self-fetches the **full** eligible pool — it takes no
`--max-issues` truncation at that layer, because grouping needs full
visibility across every eligible issue to find dependency, shared-parent,
and shared-label signals before anything is capped. It groups that pool into
batches and ungrouped singles via those deterministic signals.
**gh absent** — `autoship_group.py` already supports `--input-file` to
bypass `gh` entirely on the read side (no script change needed); this skill
supplies that file via MCP tools instead, the same way this step's `gh
absent` path worked before this pipeline replaced `autoship_discover.py`:
1. Fetch open issues labeled `autoship:ready` (or `--label`) via
`mcp__github__search_issues` (`query: "is:issue is:open label:<label>"`,
`fields: ["number", "title", "labels", "created_at"]`). `mcp__github__search_issues`
is a paginated search tool with its own default page cap — page through every
result page until exhausted, up to 500 issues, matching the gh-present path's
`--limit 500` above: without this, a repo with more than one page of eligible
issues would silently have this step see only the first page, undermining
`autoship_group.py`'s requirement to see the full eligible pool before grouping.
2. For each candidate, resolve the two fields `gh issue list --json` computes
for free but the search result doesn't carry (see the "Known gap" note
above):
- **Epic check**: `mcp__github__issue_read` (method `get`, that issue
number) — use its `sub_issues_summary.total` (or `has_children`) as
`subIssuesSummary.total`.
- **Open-linked-PR check**: `mcp__github__search_pull_requests`
(`query: "is:pr is:open <number> in:body repo:<owner>/<repo>"`). Any
result found → treat as an open linked PR (conservative: this is an
approximation of GitHub's own closing-keyword graph, not an exact
match — a false "has an open PR" only costs deferring the issue to next
round, which is safe; a false negative would let a genuinely
PR-in-flight issue double-dispatch, which is not).
3. **Known gap, gh-absent grouping only**: `autoship_group.py`'s
native-dependency and shared-parent signals need `blockedBy`, `blocking`,
and `parent` — fields this skill's REST-backed MCP toolset (the
`mcp__github__*` tools listed above) has no call for. A gh-absent round
cannot resolve them, so leave all three out of the scratch file entirely
rather than guessing — `autoship_group.py`'s signal functions already
treat a missing field as "no signal", never an error (they read it via
`.get(...)`, same as the epic/PR-check gap above). Only the shared-label
signal (which needs just the `labels` field already fetched in step 1)
still groups issues in this mode; the round still ships every eligible
issue, just solo instead of batched wherever a dependency/parent signal
would otherwise have fired.
4. **Known gap, gh-absent `confirmed_batch_members` only**: this skill's
MCP toolset (`mcp__github__search_issues`, `mcp__github__issue_read`,
`mcp__github__search_pull_requests`, `mcp__github__issue_write`,
`mcp__github__add_issue_comment`) has no call that returns an issue's
comment bodies, so a gh-absent round cannot extract
`confirmed_batch_members` from the `<!-- autoship-batch-members: ... -->`
marker. Leave the field out entirely — optional, same `.get(...)`
convention as the gap above — so `has_batch_confirmed_override` simply
never fires in a gh-absent round; a previously-confirmed batch still
groups via any shared non-autoship label it happens to carry, or ships
solo, until a gh-present round processes it.
View on GitHub