Skip to content

Auto-pick (online runner) ​

Easy, well-specified issues can be built, tested and merged without a developer at the keyboard. Spexso proposes an auto-pick label on issues that pass a rubric and a human confirms it. An online runner (GitLab CI) then picks the labeled issues on a schedule and whenever a label event arrives, runs the existing marathon on them, and queues each MR for GitLab auto-merge. The pipeline is the merge gate — the agent never merges itself.

Auto-pick is opt-in per project and off by default. While it is off, /spexso-brainstorm does not propose the label, npx spexso triage refuses to run, and the runner exits before it starts Claude.

Enabling ​

bash
npx spexso autopick enable     # writes autopick.enabled: true to .spexso/config.json
npx spexso autopick disable

/spexso-setup offers the same switch as a question (default no). Either way the change lands in .spexso/config.json, and the runner reads that file from your target branch — so auto-pick only starts once the config change is committed and merged. Enabling is a visible, reviewable act.

Emergency stop: pause the pipeline schedule and the trigger in GitLab. There is no extra mechanism.

Which issues ​

An issue is eligible when all of these hold:

RuleDefault
Size label is one of autopick.rubric.sizesxs, s
Type label is one of autopick.rubric.typesbug, enhancement, tech-debt, maintenance
The spec has an Acceptance criteria section with at least one item (requireAcceptanceCriteria); the section runs to the next heading of its own level or higher, so ### groups inside it counton
No open blockersfixed, not configurable
The spec mentions no parallel.hotspots path and no rubric.excludeTerms entry (case-insensitive substring)auth, security, xs-security, migration, mta.yaml

Override only the keys that do not fit your project — the rubric is merged key by key with the defaults:

json
{"autopick": {"rubric": {"sizes": ["xs"]}}}

The rubric only gates the proposal. A human always confirms the label:

  • /spexso-brainstorm asks "This issue qualifies for auto-pick. Add the label?" once the size is estimated and the rubric passes.
  • npx spexso triage goes over the stage::spec issues that have no label and no assignee, asks y/n for each eligible one, and lists the others with the rule they failed — they are never labeled.
  • npx spexso autopick eligible <id> prints the verdict and reasons for one issue as JSON.

Setting up the runner project ​

The runner lives in its own GitLab project (for example flexso/spexso-autopick), never in a product: a product's main pipeline would run its whole CI on every auto-pick run.

  1. Create the project and copy scaffold/autopick/gitlab-ci.yml.template (shipped in the @flexso/spexso package) to its .gitlab-ci.yml. It holds one job that only runs on schedule and trigger pipelines that set TARGET_PROJECT.
  2. Create a bot user and add it as Developer to every product that enables auto-pick, and make sure it is allowed to merge into the product's targetBranch (Settings → Repository → Protected branches → "Allowed to merge"). Commits, MRs and assignments are then attributable to the bot, and MRs carry ai::ai-generated.
  3. In every product, enable "Pipelines must succeed" (Settings → Merge requests). That setting is what makes the pipeline the merge gate: without it, auto-merge does not wait for a green pipeline.
  4. Create a token for the bot with the scopes api, write_repository and read_api (read_api lets npm ci resolve @flexso/spexso through the product's committed .npmrc — the job passes the token as NPM_REGISTRY_READ_TOKEN, see Onboarding).
  5. Set these CI variables on the runner project only: ANTHROPIC_API_KEY and SPEXSO_BOT_TOKEN (masked), SPEXSO_BOT_NAME and SPEXSO_BOT_EMAIL (plain — the commit identity).
  6. Add one pipeline schedule per product (Build → Pipeline schedules) with the variable TARGET_PROJECT=<group/product>. When the product's targetBranch is not its default branch, also set TARGET_BRANCH=<targetBranch>: the job clones that branch, so the config and the base come from it.
  7. For immediate pickup, create a pipeline trigger token in the runner project (Settings → CI/CD → Pipeline trigger tokens) and add an issue-events webhook in the product (Settings → Webhooks) to https://<gitlab>/api/v4/projects/<runner-project-id>/trigger/pipeline?token=<trigger token>&ref=main&variables[TARGET_PROJECT]=<group/product> (plus &variables[TARGET_BRANCH]=<targetBranch> when needed). The job does not parse the payload — the preflight decides whether there is work.
  8. Keep the product's Spexso commands and agents current: the runner uses the ones committed in the product, so after a Spexso upgrade run npx spexso update there and commit the result, or the runner runs stale skills.
  9. Optionally register Hetzner machines as GitLab runners for this project. Nothing else needs installing.

Each job keeps .spexso/marathon-log.jsonl and .spexso/observability/ as job artifacts for 14 days (also when the job fails) — that is where the marathon log of a run lives.

The runner supports GitLab only: with another code provider in .spexso/config.json the run fails fast, and it also fails before touching any issue when the bot identity cannot be resolved (whoami returns nothing).

The job uses resource_group: autopick-$TARGET_PROJECT, so two runs never touch the same product at once (different products run in parallel), and a 2 hour timeout.

What a run does ​

npx spexso autopick runs three steps:

  1. Preflight (no tokens). If auto-pick is disabled, it exits. Otherwise it runs the sweep below, then looks for an issue that has the auto-pick label, has stage::spec as its only stage label, has no needs::* label, is unassigned and has no open blockers. Nothing ready → exit with "nothing to pick". Checking blockers here matters: every issue event triggers a run, and a blocked issue would otherwise start an empty Claude session each time.
  2. Work. It starts claude -p "/spexso-marathon --autopick --max {maxPerRun}" with --permission-mode acceptEdits and --max-budget-usd {maxBudgetUsd}, under the project's installed permissions.
  3. Queue. In --autopick mode the marathon only takes auto-pick issues, runs them sequentially, and the merge train ends with /spexso-finish --train-merge --autopick: rebase, full-suite gate, the MR pipeline as the second half of the gate (with repair rounds while they make progress), an ownership re-check, then pr merge --auto-merge. GitLab merges when the MR pipeline is green. The outcome is queued (or taken_over / not_queued, see below). The bot never commits to your target branch directly.

Auto-pick runs skip the CHANGELOG entry, the spec archive, the learning card and the demo note: finish steps 10–11 belong to the human flow. The squash-merge commit message is the record of the change.

The sweep ​

Auto-pick never retries on its own. Every failure ends in the same hand-off: auto-pick is removed, needs::attention is added and a note explains why. A human re-adding the label is the only retry. The sweep runs at the start of every run and only touches issues assigned to the bot that still carry the label:

SituationResult
3 strikes, task budgetneeds::attention + note (marathon); auto-pick stays on and the next sweep hands it off
Rebase conflict during the trainneeds::conflict-resolution (finish); auto-pick stays on and the next sweep hands it off
Full suite red after the rebaseneeds::attention + note (finish); auto-pick stays on and the next sweep hands it off
pr merge --auto-merge keeps failing (no pipeline for the pushed commit, or the bot may not merge into targetBranch)hand-off right in finish (not_queued)
MR has merge conflicts while queuedhand-off + needs::conflict-resolution
MR pipeline failed or canceledhand-off, MR stays open
MR still open, queued for auto-merge or pipeline still runningleft alone (waiting)
MR open, not queued for auto-merge and no pipeline runninghand-off (not_queued)
MR mergedmanual-test checklist posted (mentioning the tester when the test hand-off picks one), stage::ship, then one last update: tester assigned, needs::manual-test plus handoff.labels added, auto-pick and handoff.unlabel removed (with handoff.on: "deploy" no tester or hand-off labels yet — the handoff-dev CI job does that after the DEV deploy). A failure before that update leaves the issue on the bot with the label, so the next sweep tries again
No open or merged MR (job crashed mid-run, or the MR was closed)bot unassigned + hand-off in one update
The sweep itself fails on an issue (e.g. an API error)error logged for that issue, the label stays, the other issues are still swept

The hand-off note tells the developer how to retry: close any open MR, delete branch spexso/issue-{id}, unassign, move the issue back to stage::spec (npx spexso stage {id} spec), then re-add the label.

Bot and developers ​

The bot is its own GitLab user, so the assignment lock works both ways: the marathon skips issues assigned to a developer, and /spexso-start blocks on an issue assigned to the bot.

  • A manual start wins. When a human runs /spexso-start on an auto-pick issue, the label is dropped so the issue leaves the bot's queue. To hand it back to the bot: unassign it, move it back with npx spexso stage {id} spec, and re-add the label.
  • Atomic branch claim. On the runner path /spexso-start --autopick claims spexso/issue-{id} with a compare-and-swap push that only succeeds if the branch does not exist yet. A rejected claim is a hand-off: the bot unassigns itself, removes auto-pick, adds needs::attention and notes that the branch already exists — so the issue is not picked again on the next run. To retry, delete the branch and re-add the label. An existing remote branch is always a skip for the bot (a developer only gets a warning).
  • Ownership re-check. Right before the push and auto-merge, the bot re-reads the assignee. If it is no longer the bot, it removes auto-pick, posts "taken over by @user, auto-pick stopped", and leaves the MR open without auto-merge.

Known ceiling

A developer who starts an issue just after the bot's branch claim but before its assignment only gets the existing "branch exists" warning. If you see this in practice, the same claim can be added to the manual /spexso-start.

Costs and limits ​

KnobDefaultEffect
autopick.maxPerRun3Issues per run (--max)
autopick.maxBudgetUsd10Hard spend cap per run (--max-budget-usd)
Job timeout2hKills a runaway run
modelBySizexs/s → SonnetTester and builder use the cheaper model on small issues

A run with nothing to pick spends no tokens: the preflight and sweep are plain code.

Known ceiling

Issues queued in the same run were each rebased onto a target branch that did not yet contain the other queued MRs. A semantic conflict between them is caught by the main pipeline after the merge, not before. Set maxPerRun to 1, or use GitLab merge trains / merged-results pipelines, if that matters for your product.