Auto (Autonomous Backlog Iteration)
Ships exactly one Linear issue per invocation, end-to-end and unattended: finish any in-flight work, pick the best next issue via /next specified (certified issues only) — or, in targeted mode (/auto BF-123), take the issue named in the invocation — ship it via /full auto wt, record the outcome, emit a tagged final line. Continuous operation is /loop /auto — each loop iteration is one issue, and the loop ends itself when the backlog drains (NO-CANDIDATES) or the circuit breaker trips (AUTO-HALTED).
Why one-issue-per-invocation instead of an internal "keep going" loop: in-prose anti-stop scaffolding is the documented failure mode of autonomous macros (/full records it failing three times before its Stop hook existed). /loop's wakeup machinery is the reliable recurrence mechanism; the full-continue.sh Stop hook guards the intra-issue start→finish handoff, and the auto-heartbeat.sh Stop hook guards the loop heartbeat the same way — added after prose failed a third time (BF-701, BF-665, BF-695). This skill keeps its reliability in those hooks and in /loop's machinery, not in prose scaffolding — it composes the enforced pieces.
Why worktree mode is not optional: in-place mode would leave the checkout on issue N's branch, and /start's "already on a non-main branch → stay on it" rule would stack issue N+1 onto it — every subsequent issue cascading onto the first issue's branch. wt mode forks each issue from the source branch and /finish merge folds it back, so successive issues chain correctly (each fork sees the prior merges) with no stacking. /auto therefore ALWAYS dispatches wt.
Authorization — read this first
Invoking /auto (directly or via /loop /auto) is the explicit, run-scoped standing grant for commits and pushes. standards/git.md forbids treating "commit" or "push" as a session-wide grant; /auto is the single named exception, because unattended issue-shipping is its entire documented contract. The grant covers exactly: the /finish auto commit+push of each issue this run ships. It does not cover force-pushes, history rewrites, or committing work that cannot be attributed to a Linear issue (see Preflight). Every shipped change is audited via the plan comment (/start auto Step 7) and completion comment (/finish Step 4) on its Linear issue.
Unattended-run prerequisites
This skill cannot change permission modes or pick its own model. For a genuinely walk-away run:
- Launch with
--model opus[1m] --effort xhigh --autocompact 500000. Do NOT useopusplan: it means "Opus in plan mode, Sonnet otherwise", and Step 3's/full autodispatches/start auto, which skipsEnterPlanModeentirely (/startStep 6). The Opus branch never fires, so anopusplanrun executes end-to-end on Sonnet.[1m]gives one oversized issue headroom, but unbounded cross-iteration accumulation is the dominant fleet cost: onopus[1m]the default compaction threshold sits near the 1M window, so a looping session never compacts, and the 2026-08-13/14 fleets processed 91% of their billable volume at >200k context (cache reads scale linearly with context — this is what let 3 sessions exhaust a 5h window that 9 leaner sessions survived).--autocompact 500000caps the working set; cross-iteration state lives intmp/auto-state-*.jsonand Linear, so compaction between issues loses nothing. Not lower: compaction triggers at ~90% of the window, and the trigger must clear a deep-issue session's post-compact floor (~152–177k) plus the >=110k live working set a review/fix loop re-reads after every compact plus one ~130k worst-case ingestion — 150k thrash-aborted the 2026-08-14 fleet at launch, and 300k fell into a mid-review compaction orbit (9 compacts in 36 min) the same night (doc/compacting-investigation.md, verdict log). Never resume a deep fleet session; recover in-flight issues via fresh targeted/auto BF-XXXsessions. Fable 5 (--model fable) is the alternative for unusually large issues — better long-horizon retention — but it runs safety classifiers that can returnstop_reason: "refusal", which reaches Step 4 as an unrecognized-tag failure and counts against the circuit breaker. - Run the session in a permissive mode; consider
/fewer-permission-promptsfirst to seed a project allowlist. Interactive sessions (a watched terminal/VSCode): auto-accept edits orclaude --permission-mode acceptEditssuffice — someone can answer the occasional Bash prompt. Background sessions (claude --bg,/fleet-launch): use--permission-mode auto— acceptEdits only auto-accepts file edits, so the first gated Bash command (observed:/start wt's worktree validation) stalls the session at a prompt nobody can see, before its first pick. - A permission prompt mid-run does not break anything — the run pauses until answered and
/loopresumes normally. Expect occasional prompts on milestones that touch unusual commands (e.g., native-bridge/hardware work). - Context growth across iterations is handled by the harness's automatic summarization.
/compactand/clearare user commands — never attempt them;tmp/auto-state-<runKey>.json(Step 4) is the cross-iteration memory that survives summarization. You have ample context; never stop, summarize, or propose a new session on account of context limits.
Operating unattended
Nobody is watching in real time, so a question ends the run instead of resolving it. For any reversible action that follows from the invocation, proceed — every prompt site in the skills this dispatches already documents an auto default, and taking it IS the answer. The exceptions are the ones the workflow names: an unattributable dirty tree, a live sibling's worktree, an uncertified target. Those halt with a tag, which is not the same as asking.
Ground progress claims in tool results, not recollection. Before recording an outcome in Step 4, each claim traces to something this session actually observed — a tag from the dispatched skill, a git status, a script's exit code. Report faithfully: if a check failed, say so with its output; if a step was skipped, say that.
Before ending a turn, read your last paragraph. If it is a plan, a question, or a promise about work not yet done ("I'll ship it next", "waiting on the review"), do that work now with tool calls instead. Every turn ends on either a lifecycle tag or a genuine block.
Fleet-view visibility — issue-keyed narration
The claude agents view titles a session from its FIRST prompt (Haiku-generated, fixed once set; claude -n and /rename are user-side levers this skill cannot pull), so a loop-picked run's title crystallizes from Step 0/1 preflight mechanics and never contains the issue key — only targeted mode's key reaches the title, via the invocation text. The right-hand status line is different: it is re-summarized from recent session activity (~15s cadence plus turn end), so what this session says is what the fleet operator sees. Therefore: from the moment the issue ID is fixed (Step 2's pick, or the targeted argument) until the iteration tag, begin every user-visible chat status line with <ISSUE-ID>: — narration between tool calls, checkpoint notes, and progress remarks alike. Keep the lines short; the summarizer compresses them. This composes down through the skills this session dispatches (/full, /start, /quality-review run in-session and inherit the rule). Lifecycle-tag lines are exempt — their <TAG>: <ISSUE-ID> — ... contract already carries the ID and must stay TAG-first.
Self-paced loop pacing
Under self-paced /loop (invoked without an interval), every iteration must end with a ScheduleWakeup or the loop dies — and the delay is this skill's call, not the tool's default. On AUTO-CONTINUE, schedule the minimum delaySeconds (60s): the backlog is the work queue, Step 2 already established it is non-empty, and there is no external state whose change is being waited on. ScheduleWakeup's 1200–1800s idle-tick guidance is written for polling loops (CI, deploys, remote queues) and does not apply here — inheriting it inserts 20–30 idle minutes between every shipped issue, which is how one observed run spent ~3.5 hours doing nothing across 9 ships. The only delays this skill prescribes are the ~900s API-error retry cadence (next section) and the ~1800s fallback heartbeat (next paragraph). On NO-CANDIDATES or AUTO-HALTED, end the loop explicitly with ScheduleWakeup(stop: true) — it also cancels a pending heartbeat, which plain not-scheduling would leave to fire one more no-op turn against the sticky terminal state.
The fallback heartbeat is armed at turn end, never mid-turn. ScheduleWakeup is turn-ending by contract: /loop's dynamic-mode step 3 says "the turn ends as soon as that tool returns", step 4 says to call it "as the last action of this turn", and the tool's own result reads "Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives." It therefore cannot be armed before an iteration's work: doing so ends the turn before Step 0 runs and idles the session for the full delay. Arm it as the last action of any turn that ends mid-iteration (~1800s), and supersede it with the 60s tick when the iteration completes — only one dynamic-loop wakeup is ever pending, since each ScheduleWakeup cancels the previous (the runtime telemeters this as superseded_count). The safety net against an accidental turn-end is the Stop hook, not pre-arming: hooks/auto-heartbeat.sh blocks any turn ending inside a self-paced /loop /auto iteration with no ScheduleWakeup since the anchor and re-drives the arming (1800s mid-iteration, 60s post-AUTO-CONTINUE), giving up after 3 attempts. Both observed stall shapes — BF-701 (ended on SHIPPED-MERGE without running Step 4) and BF-665 (announced a re-review dispatch it never made) — are exactly what that hook now catches. Extra firings remain benign: one landing right after an AUTO-CONTINUE tick or against a terminal state reads as a status check and is absorbed by the re-entry rules and Step 0's sticky states. (Fixed-interval /loop and one-shot/targeted runs get no heartbeat: the interval timer already re-fires the former, and a wakeup would wrongly re-fire the latter.)
Transient API failures are never failures
A Claude API error — overload (529), rate limit (429), or transient 5xx — from any tool call or delegated agent is infrastructure, not an issue failure. Never count it toward consecutiveFailures, never mark the issue failed, never emit AUTO-HALTED for it — except the beyond-ceiling quota reset carved out below. Instead: retry with a short delay (respect a Retry-After header or stated reset time when one is given). If retries keep failing, fall back to a 15-minute wake-and-retry cadence — under self-paced /loop, end the turn with a ScheduleWakeup-style ~900s delay and resume the same iteration on wake; under fixed-interval /loop, simply end the turn and let the next interval retry. A retry turn still ends with a tagged final line: AUTO-CONTINUE: <ISSUE-ID> paused mid-issue (Claude API <error>); retrying in ~15m. — or, when the failure hit before an issue was picked (Step 0/1/2, including /next itself), AUTO-CONTINUE: paused pre-pick (Claude API <error>); retrying in ~15m. The next iteration resumes where it left off — Step 1's preflight checks route back into the same issue (/start//full are idempotent on it).
A reset beyond the wakeup ceiling is an environment halt, not a slower poll. ScheduleWakeup clamps delaySeconds to [60, 3600], so an error stating a reset hours or days out — a weekly or multi-day quota rather than a per-minute rate limit — cannot be waited on: the ~900s cadence above would fire hundreds of futile wakeups against the same limit, and a 3-day block sits inside the dynamic loop's own 7-day age-out, so nothing ends it. This is the one carve-out from "never halt for it" in Failure policy: when the stated reset is materially beyond the 3600s ceiling, treat it as an environment halt — the same category as Step 1's unattributable dirty tree and Error Handling's broken /next. Set status: "halted" with a reason, and emit AUTO-HALTED: Claude API quota exhausted (resets <stated time>) — wait for the reset, then delete tmp/auto-state-<runKey>.json and re-invoke /auto. Under self-paced /loop, pair it with ScheduleWakeup(stop: true) as any AUTO-HALTED does.
Re-test before halting on a quota observation you did not just make. The halt above is the only irreversible response in the failure policy — it is sticky across re-invocations (Step 0) and ends the loop — so it is owed a current reading, and a quota error already in your context is not one. The observation goes stale in exactly the case that produces it: an exhausted account stalls the session, and the session resumes only because something changed underneath it — a reset, an account switch, a plan change — none of which announce themselves in the transcript. The stale error is then the most recent thing you saw, which reads as present state. Observed on the 2026-08-06 BF fleet: d7d04bd6 resumed after a 2.2-hour stall and declared the run halted on the error it had hit before it, and one cheap dispatch afterward proved quota was fine; a human had to push back to recover the session. Before writing status: "halted", spend one trivial call (a Bash echo, a one-line Agent) and halt only if that call fails on quota. Treat any gap between the error and the halt decision as making it stale — a turn boundary, a wake, a resumption — and treat a resumption after a long silence as positive evidence the condition may have lifted, never as continuity. The check costs a few tokens against a halt that costs the rest of the run.
It is still not an issue failure: record no outcome and leave consecutiveFailures and reviewBlocks untouched, exactly as the other environment halts do. Leave the in-flight issue In Progress with its worktree and branch intact, and follow the failure rows' outward-markers-first ordering minus the label — post a Linear comment naming the block and the reset time, then release the worktree's ownership stamp (~/.claude/scripts/wt-disown.sh <main-checkout>/.claude/worktrees/<issue-id-lowercased>, best-effort), then write the state file. The release is not optional: without it the stamp reads alive for this harness process's whole lifetime, so Step 1 skips the worktree, Step 2 drops the candidate, and start-wt-create.sh refuses — the BF-525 trap, where the resume path the comment advertises is one the stamp itself blocks. Do NOT apply stalled; that marker is for genuine pipeline failures, and this is infrastructure. After the reset a human deletes the state file and re-invokes: /start and /full are idempotent on the issue, so a fresh /loop /auto — or a targeted /auto <ISSUE-ID> — resumes it.
Non-Claude API failures of the same shape count too. A failure from another service's API that is plainly transient infrastructure — linear-cli returning HTTP 5xx / Service Unavailable, or a timeout — is handled identically to the Claude errors above: probe once (linear-cli issues get <ISSUE-ID>), retry, and record no issue outcome for it. This includes one that reaches this skill as a terminal tag from a dispatched skill: a /start auto-mode BLOCKED-ON-REVIEW: <ISSUE-ID> — claim update failed (linear-cli error) whose script stderr shows a 5xx is a service blip, not a review block — re-dispatch the same /full auto wt <ISSUE-ID> (idempotent on the issue and its worktree) instead of applying Step 4's failure row, which would post a failure comment and stalled label on a healthy issue and burn one of the 4 reviewBlocks slots. Authentication, permission, and unknown-state-name failures are NOT transient and keep their documented handling.
Heartbeat re-entry mid-iteration. Under self-paced /loop, a fallback wakeup may re-deliver this skill while the current iteration's delegated work is still in flight in this same session. That re-entry is a status check, not a new iteration: check on the outstanding work (ping stalled agents, verify state), re-arm the fallback at 1200–1800s (ScheduleWakeup's own "long fallback heartbeat" guidance — the delegated Agent call's task notification is the real wake signal here, not the timer; this is NOT the 60s AUTO-CONTINUE figure above, which is scoped to the no-external-wait backlog tick), then end the turn on that call and wait. Do not run Step 0's re-anchor / ExitWorktree — the in-flight iteration is still registered on its worktree, and releasing that registration would guard-block its outstanding developer writes (Step 0's ExitWorktree is only safe at a true iteration boundary, when no delegated work is live). Do not re-run preflight or dispatch /next, and do not treat the in-progress worktree as orphaned — Step 1's resumption path is for a new invocation after a died iteration, not for one still running here.
Stall recovery on re-entry. When re-entry finds no delegated work in flight and no iteration tag yet, the previous turn stalled — it ended without a tool call (an announced-but-unmade dispatch, or an inner skill's closing message obeyed as a turn end). Recover by resuming the iteration exactly where the transcript left off: make the missing dispatch, or — if /full's terminal tag was already emitted — go straight to Step 4. This recovery, not the status check, is the heartbeat's primary purpose.
A human prompt that recovers a stalled iteration resumes the ITERATION, not just the issue. When an operator attaches to a stalled run and prompts it forward ("limit lifted, complete this issue"), completing and verifying the issue is not the end of the work: run Step 4 — record the outcome in the state file and end on a terminal status or wakeup — before reporting back. Nothing mechanical re-drives this: hooks/auto-heartbeat.sh deliberately stands down after a human message (human-override — it must not fight an operator mid-decision), so the bookkeeping either happens in this turn or not at all. Measured 2026-08-16: a weekly-limit cutoff killed a session in the seconds between /full returning and Step 4; the operator's recovery prompt got BF-844 verified shipped, the session reported and stopped, and the ledger read active, shipped: [] for a merged issue until the fleet retro corrected it by hand.
User stop semantics
A user instruction to stop the loop ("do not run another loop", "stop after this one", "no more iterations") ends recurrence, not the in-flight iteration: schedule no further wakeup, and complete the current issue normally — every inner loop keeps its documented budget. A red re-review during wind-down enters /quality-review's fix loop exactly as it would have before the instruction; "not shippable yet" is the fix loop's input, never a reason to park (the observed failure mode: a cycle-2 red re-review parked as terminated-with-open-items, a verdict whose only documented producers are the 5-cycle ceiling, the regression cap, and contamination — there is no user-stop producer). Parking the in-flight issue requires explicit words — "park it", "stop now", "abandon it" — and is never inferred from a stop instruction, a failing verdict, or their combination. Two further rules: once you have announced a wind-down default ("I'll finish this issue unless you say otherwise"), new information reopens that decision only through the user — never by silently switching to the branch they didn't choose; and any surprise decision point during wind-down is ASKED, not improvised — the user just spoke, so they are present. The conservative-abort posture of Failure policy is for unattended operation; the moment the user is interactive, "ask" dominates "abort".
Parallel sessions
Multiple /loop /auto sessions on the same repo are supported. They coordinate through shared session-agnostic state, never through each other:
- Linear is the claim registry.
/nextoffers only unstarted states (Backlog/Planned/Todo), so a claimed (In Progress) issue is invisible to every other session's pick. The residual pick race — two sessions taking the same top candidate before either claims — is closed at the git layer:start-wt-create.shruns under the repo lock and refuses to reuse a worktree owned by a live other session, so exactly one session wins and the loser exitsSKIPPED-BLOCKED(a skip, not a failure). - Owner liveness distinguishes died from live. Every
/start wtstamps the owning harness PID + start time into the worktree identity; Step 1 resumes only a worktree whose owner is provably dead. Alive → a sibling's active work; unknown → parked for a human. Never resume on "In Progress + mine" alone. Known limitation: the PID tier is harness-process-granular, and under a fleet it fails in BOTH directions (BF-1103). A session that dies inside a still-live harness (one closed window among several, a long-done fleet session whose shared root lives on) readsaliveuntil that process exits — its worktree unresumable and its issue unflagged for that window — while a LIVE fleet session can readunknownordead: the pid walk resolves nothing when the pool chain is reparented to pid 1 (stamps carry a blank pid), and the shared fleet root's exit reads every still-live sibling dead at once.wt-identity.shtherefore probes the owner's SESSION first — the daemon's per-session job dir (state.jsonlifecycle state, activity mtimes) answers at session granularity for background sessions, in both directions — andstart-wt-create.shfails closed on a foreign claim whose liveness staysunknown; the pid tier remains the fallback for owners with no job dir (interactive sessions). Residual stalls surface once the harness restarts or the session state goes terminal, via Step 1's dead-owner sweep and itsstalledlabeling. Thereleasedstate closes the tail of this window for stalls this skill itself declares: Step 4's failure rows and Step 1's flagging release the stamp (wt-disown.sh) when they label, so astalledissue is immediately resumable by any session even while the shared harness process lives on. - Run state is per-session (
tmp/auto-state-<runKey>.json): shipped/canceled/skipped/failed lists and the circuit breaker are scoped to one session's run. Parallel runs have independent breakers — systemic breakage can cost up to 2× the failed attempts before both trip, which is accepted. - The merge queue is cross-session. A
DEFERRED-MERGEmarker under.claude/merge-queue/gates resumption regardless of which session shipped the issue.
One caveat: do not run an in-place (non-wt) /start on the same repo while /auto runs. In-place work dirties the main checkout with no ownership stamp, so Step 1's dirty-tree preflight cannot distinguish it from a died run's leftovers and would try to /finish it mid-edit. Interactive work alongside /auto belongs in a worktree — /start interactive <ISSUE-ID> (worktree-implied) exists for exactly this.
Arguments
/auto [pr] [TEAM[,TEAM...] | ISSUE-ID]
Tokens are case-insensitive and order-insensitive.
propens a PR per issue instead of merging (pass-through to/full). Caveat: inprmode the source branch does not advance until PRs merge, so a dependent issue forks without its predecessor's code — usepronly when the queued issues are independent or a human is merging promptly.pris passed ONLY when this literal token appears in the invocation — never inferred. Not from how recent issues shipped, not from a project memory orCLAUDE.md, not from a sibling session's choice, not from a repo whosemainis all PR merges. An unattended run's default is merge, and "every recent issue shipped via PR" is a product of the interactive convention rather than evidence about this run — treating it as evidence is circular, and it drifted three consecutive issues that way over one 2026-08-20 run before anyone noticed.- A team scope (
BF, or a comma listPL,BF): restricts the whole run to those teams' certified backlogs — forwarded to Step 2's pick asteam:<KEYS>. Without it, scope follows/next's resolution ($LINEAR_TEAM, else every team in the workspace). At most one team token is accepted, and it MUST validate before it is trusted: uppercase it, then check every comma-part against the workspace's real team keys (linear-cli teams list -o json, case-insensitive). Any part that is not a real team key —wt,auto,team, a typo — is an unrecognized argument: emit the error below and STOP. An unattended run must never launch scoped to a nonexistent team (it would silently mark itselfdrainedagainst an empty backlog). (pris reserved for the PR flag, sopr,BFfails validation by design; a workspace whose team is literally keyedPRscopes via$LINEAR_TEAMinstead.) - An issue ID selects targeted mode (
/auto BF-123,/auto pr BF-123) — ship exactly that issue, skipping Step 2's pick. Parse order: stripprfirst; a token matching^[A-Za-z]+-[0-9]+$is the issue ID (normalize via~/.claude/scripts/detect-issue-id.sh --validate-only --input <token>); only then is a remaining bare token tried as a team scope. At most one issue ID, and an issue ID together with a team scope is an error (the target already names its team):Targeted mode takes no team scope — /auto <ISSUE-ID> ships exactly that issue.
Targeted mode requires the specified label — the invariant stays one sentence: /auto ships only certified specs, picked or targeted (standards/issue-spec.md). Probe before dispatching anything:
linear-cli issues get <ISSUE-ID> -o json | jq -r '.labels.nodes[].name' | grep -qix specified
Label absent → refuse and stop with a plain error — no lifecycle tag, no state change; this is invocation-time argument validation and the user is present, having just typed the ID: <ISSUE-ID> is not certified (no specified label) — run /spec <ISSUE-ID> to certify it, or /full wt <ISSUE-ID> for an interactive run. (A probe failure from auth/network is Error Handling's linear-cli territory, never a silent pass.)
The probe checks specified and one exclusion — human. A human-labeled target is refused the same way (probe with grep -qix human on the same command): that label marks work a person performs, so no mode of this skill can ship it (standards/issue-spec.md) — <ISSUE-ID> is human-owned work (human label) — assign a person, or /spec <ISSUE-ID> to split out an agent-shippable slice. A solo target is expressly legitimate: that label hides an issue from /next's ranking precisely so it can only arrive here, by a human typing the ID once no fleet is running (standards/issue-spec.md). Never extend the probe beyond these two — refusing solo would strand work that is fully automatable and leave no path to ship it.
Targeted mode narrows the workflow in exactly four places, each marked at its step: Step 0's sticky terminal-state gate is bypassed, Step 1's preflight is scoped to the target, Step 2 is skipped, and Step 4 never transitions status or schedules a wakeup. Everything else — always-wt, the run-scoped commit/push grant, auto defaults, outcome recording, the failure Linear comment and stalled label — is identical. It is one-shot by nature: run it directly, not under /loop (a re-fired loop would just skip-block against the now-terminal issue).
Error on anything else — a second team-shaped token, a second issue ID, or a token failing team validation: Unrecognized argument 'X'. /auto accepts optional 'pr' plus either one team scope matching a real team key (e.g. BF or PL,BF) or one issue ID for a targeted run; worktree mode is always on.
Workflow (one iteration)
Step 0: Entry gate — re-anchor and read the run state
Re-anchor cwd to the main checkout first. A prior iteration may have ended with cwd inside .claude/worktrees/<id> and the session still registered on that worktree (/start wt Step 0 enters via EnterWorktree, which switches cwd and registers it as the session's isolation root; only the merge path cd's back, and a shell cd never releases the registration). Release it first: call ExitWorktree(action: "keep") — a no-op when no registration is active, and it never removes a path-entered worktree — clearing a registration that may point at a worktree the prior iteration's /finish merge (or the reaper daemon) has since deleted, so the next /start wt EnterWorktree is a clean first entry rather than a switch away from a dangling root. Then resolve the main checkout (the first worktree <path> line of git worktree list --porcelain, unambiguous even with spaces in the path) and cd there before anything else. This skill's run-state file (tmp/auto-state-<runKey>.json — defined next) lives in the main checkout; write it via the shell (see the bgIsolation note below) — the shell bypasses the Write-tool guard, so it is safe whether or not a worktree is registered. (Reads are unaffected.) Every read or write of the run-state file — Step 0, Steps 1–2's terminal transitions, Step 4, and all Error Handling transitions — uses <main-checkout>/tmp/auto-state-<runKey>.json (carry the resolved absolute path through the iteration); a state file touched inside a worktree would fragment the run state (resetting the breaker and skip lists, making halts non-sticky) and be deleted with the worktree.
Session identity → per-session state path. Resolve this session's identity alongside the re-anchor: run ~/.claude/scripts/session-identity.sh and read RUN_KEY (the per-session id when the harness provides one, else the stable harness PID) plus HARNESS_PID/HARNESS_PID_START (the liveness anchor embedded in the state file). Never key on raw $PPID: in a claude agents fleet the tool shell's parent is a transient bg-spare pool process whose pid changes between commands, and even solo, a harness restart (extension update) changes pids mid-session — the session id survives both. The run state lives at <main-checkout>/tmp/auto-state-<runKey>.json — per-session by construction, so parallel /loop /auto sessions never adopt or clobber each other's run state (a shared path is exactly how a second session once "resumed" the first's run — see Parallel sessions). Every iteration re-derives the same RUN_KEY; wherever this skill (including emitted tag texts) says tmp/auto-state-<runKey>.json, substitute the actual value. Two hygiene rules on entry: (1) if the file exists and RUN_KEY is a session id, refresh its embedded pid/pidStart to the current values when they differ (a harness restart changed the pid, not the run); if RUN_KEY is the pid fallback, a pid/pidStart mismatch means PID recycling — recreate it empty rather than inherit foreign state. (2) Best-effort GC, and it has exactly one safe target: a sibling tmp/auto-state-*.json whose status is "active", whose embedded pid is no longer a live process, and whose shipped/canceled/skipped/failed lists are all empty — a run that died without recording an outcome. A file holding any recorded outcome is a ledger and is never deleted, whatever its status. Never delete a file whose status is drained or halted. That file is not a leftover, it is the run's ledger: its shipped/canceled/skipped/failed lists are the only record of what a finished session did, and /fleet-retro discovers sessions from exactly these files. Deleting one erases a completed run from history — including, when the run died at preflight, the evidence that it died. (Ledger expiry is /fleet-launch's job, not this GC's: the next launch clears dead prior-run ledgers after the between-runs window in which /fleet-retro reads them — this skill's GC still never touches them.) And never trust pid alone to tell siblings apart in a fleet: session-identity.sh resolves HARNESS_PID to the fleet root when running under claude agents, so every session in a fleet embeds the SAME pid, and kill -0 answers identically for all of them — alive while the root lives, dead for all of them at once when it exits. (It also goes empty mid-run, which is how a session ends up embedding some other pid entirely.) And status alone is not sufficient: never delete an active file whose outcome lists are non-empty. The status check protects a run that reached a terminal state; it does not protect one that shipped work and was then killed — in a fleet the common shape rather than the edge case, since the shared root pid above reads dead for every session at once. Such a file is a ledger too: /fleet-retro still recovers the session from its transcripts (fleet-metrics.py's second discovery pass), but the recorded shipped/canceled/skipped/failed counts are gone and that row falls back to transcript tags, which undercount a compacted session. And because a dead shared pid is not evidence this sibling died, the deletion can instead reset a live run's breaker and skip lists mid-run. The only safe target is an active file whose shipped, canceled, skipped, and failed are ALL empty — a run killed before it recorded anything. Content, not liveness, is what protects a ledger; the pid check only bounds which empty active files are worth clearing. Observed 2026-08-05: eleven state files, seven of them active on one dead fleet-root pid and each recording a shipped issue — every one a literal match for the safe target as written. Observed 2026-08-04: a surviving session ran this GC at 01:53 UTC, found the shared root pid dead, and deleted two drained ledgers holding 7 of the fleet's 12 ships — the retro then measured half the run. A legacy un-suffixed tmp/auto-state.json predates per-session state and belongs to no current run: ignore it (mention once that it can be deleted).
Background-job note (bgIsolation). In a background session the harness's worktree-isolation guard refuses Write-tool edits outside the registered worktree — and /start wt Step 0 registers its worktree via EnterWorktree, so <main-checkout>/tmp/auto-state-<runKey>.json (which this skill REQUIRES to live in the main checkout) is outside it. That file is gitignored, run-scoped bookkeeping owned by this skill, not project code: write it via the shell — always, as Step 0's re-anchor prescribes (e.g. printf '%s\n' '<json>' > <main-checkout>/tmp/auto-state-<runKey>.json); the shell printf bypasses the Write-tool guard entirely, and unlike a Write-tool edit it never trips the read-before-Write requirement instead of relocating it into a worktree (fragments the run state) or setting worktree.bgIsolation: none (removes the guard for all code edits, not just this file — and /start Step 8 forbids it). Between issues, Step 0's re-anchor calls ExitWorktree(action: "keep") to release the prior iteration's registration before the next /start wt re-registers — so the next EnterWorktree is a clean first entry, never a switch away from an already-deleted worktree.
Read <main-checkout>/tmp/auto-state-<runKey>.json (see Step 4 for the shape). One file = one run — and one session = one run: a new session's PID names a fresh file, so it starts a fresh run automatically; within a session, the file is what makes terminal states sticky across /loop re-invocations. If it exists and status is halted or drained, do not proceed — re-emit the stored terminal condition using the reason field (every transition that sets a terminal status MUST also set reason to the one-line summary it emitted):
halted→AUTO-HALTED: run previously halted — <reason from state file>drained→NO-CANDIDATES: <reason from state file>
The stored reason already ends with its next action (usually "delete tmp/auto-state-<runKey>.json…"), so append nothing. If a terminal state file predates the reason field (or it's empty), emit the tag with reason unavailable — see the run's Linear comments and the shipped/canceled/skipped/failed lists in tmp/auto-state-<runKey>.json; delete it to start a fresh run. — do not improvise a cause.
Targeted mode bypasses this terminal-state re-emission — an explicitly-typed issue ID is the human intervention the halt asked for, so proceed with the run state as-is rather than parroting the stored condition back at the user. Leave status untouched (the stored halt/drain still governs the next bare invocation); the targeted outcome still records into the lists per Step 4.
This makes the circuit breaker hold even under fixed-interval /loop (e.g., /loop 15m /auto), which re-invokes regardless of the previous iteration's tag. A human starts a new run by deleting the state file (its skip/fail exclusions are run-scoped and would otherwise wrongly suppress issues whose blockers have since been resolved) — or simply by starting a new session, whose fresh PID names a fresh file (Step 0's GC eventually clears the old one). If the file is missing or unreadable/corrupt, recreate it empty (status: "active", plus this session's pid/pidStart) and continue — worst case the breaker takes one extra failure to trip.
Step 1: Preflight — finish in-flight work
Targeted mode scopes this step to the target. The checks below protect the run, not the backlog, so they narrow: for check 1, dirt attributable to the target itself is this run's resumption — dispatch the same Skill(skill: "finish", args: "auto <ISSUE-ID>") as the attributable row; dirt attributable to a different issue, or unattributable, refuses with a plain error and no state change (the user asked for the target, not a surprise ship of something else, and the loop-mode halted transition exists to stop recurrence a one-shot doesn't have): Main checkout is dirty on <branch> — if the work belongs to another in-progress issue of mine, run bare /auto to preflight-finish it; otherwise resolve it manually (bare /auto would only halt on it). Then re-run /auto <ISSUE-ID>. Check 2 reduces to the target's own merge-queue guard: if <main-checkout>/.claude/merge-queue/<target-id-lowercased>.json exists, refuse — the issue already shipped and the drainer owns it (/merge-queue to inspect). Skip the general orphan sweep (foreign worktrees are not this invocation's business — note them in the summary sentence at most); a leftover worktree for the target itself needs no special handling here, because Step 3's /full dispatch already resumes a dead owner's worktree idempotently and refuses a live one (SKIPPED-BLOCKED).
Two checks, in order:
-
Dirty main checkout. Run
git status --shortandgit branch --show-current. If dirty, first rule out a transient sibling operation: a parallel session's/finish merge(or locked worktree create) briefly mutates the main checkout under the repo lock, and a status read that races it looks like orphaned WIP. Re-run the status under that same lock —~/.claude/scripts/with-repo-lock.py "$(git rev-parse --path-format=absolute --git-common-dir)" git status --porcelain— which simply waits out any in-flight critical section; if the locked re-check comes back clean, there is no dirt (proceed to check 2). Still dirty → attribute the work by branch name only — the branch must itself contain an issue ID ([a-z]+-[0-9]+segment). Do NOT usedetect-issue-id.sh's commit-subject fallback here: in this workflow every commit subject contains an issue ID, so the fallback would attribute any foreign WIP to whatever issue the last commit mentions and ship it. Additionally verify the resolved issue isIn Progressand assigned to me.- Attributable and not already in this run's
failedlist → dispatchSkill(skill: "finish", args: "auto <ISSUE-ID>")— no mode token;finish-detect-mode.shdetects worktree-vs-standard from where the work actually lives. Treat its terminal tag as this iteration's outcome (Step 4) and do not also start a new issue. - Attributable but already in
failed→ the previous attempt to finish this same dirty tree failed and the tree is still dirty; retrying is a loop, not progress. Setstatus: "halted"and emitAUTO-HALTED: dirty working tree for <ISSUE-ID> failed to finish and needs a human (see its Linear comment) — resolve, then delete tmp/auto-state-<runKey>.json and re-invoke /auto.(Environment halt — does not touchconsecutiveFailures.) - Not attributable (branch has no issue ID, or the issue isn't In Progress+mine) → set
status: "halted"in the state file and emitAUTO-HALTED: dirty working tree on <branch> not attributable to an in-progress Linear issue — inspect and commit/stash manually, then delete tmp/auto-state-<runKey>.json and re-invoke /auto.(This is an environment halt — it does not touchconsecutiveFailures.)
- Attributable and not already in this run's
-
Orphaned issue worktrees (a prior
wtiteration died mid-implementation — invisible togit statusin the main checkout). Rungit worktree list; a candidate is each.claude/worktrees/<id>whose issue isIn Progressand assigned to me and not already in this run'sshipped/canceled/skipped/failedlists (same exclusion as Step 2 —shippedmatters here too:DEFERRED-MERGEandSHIPPED-PRdeliberately leave an intact worktree with the issue In Progress, and "resuming" one would re-implement shipped work or race the queued merge). "In Progress + mine + worktree exists" does NOT by itself mean orphaned — a parallel session's live iteration looks identical. Apply two further gates to each candidate, in order:- Queued merge: if
<main-checkout>/.claude/merge-queue/<issue-id-lowercased>.jsonexists, the issue already shipped asDEFERRED-MERGE— possibly from another session, which this run'sshippedlist cannot see — and the queue drainer owns it. Skip; never resume it. - Owner liveness: run
~/.claude/scripts/wt-owner.sh <worktree-abs-path>and readOWNER_ALIVE/OWNER_IS_MEfrom its output. Adjudicatereleasedfirst — it outranksOWNER_IS_ME, because a worktree this session itself released in an earlier iteration is a candidate again, not in-flight work.released(a prior owner deliberately relinquished the stamp viawt-disown.sh— the stall/abandon path) ordead(a genuinely died session) → resume it — dispatchSkill(skill: "full", args: "auto wt [pr] <ISSUE-ID>")(idempotent resumption per/full's edge cases) and treat its tag as this iteration's outcome.alive+OWNER_IS_ME=0→ a parallel live session is working this issue right now: not orphaned — skip silently (their work, their outcome; do not add it to any list).alive+OWNER_IS_ME=1→ this session's own in-flight iteration (the heartbeat re-entry rule above) — skip.unknown(unstamped legacy worktree, or liveness undeterminable on this platform) → do NOT auto-resume — a live owner cannot be ruled out, and resuming live work re-implements or corrupts it; leave the worktree parked, and note it in this iteration's summary sentence so a human can resume it manually (/full wt <ISSUE-ID>) if it is in fact dead.
Resume at most one per iteration.
Flag the rest. Any additional dead-owner candidates beyond the one resumed are stalled work this iteration won't reach: apply the
stalledlabel to each (~/.claude/scripts/linear-add-label.sh <ISSUE-ID> stalled— best-effort, idempotent) so they stay discoverable in Linear even if the run later halts before reaching them, then release each one's ownership stamp so any session can resume it without tripping the create gate:~/.claude/scripts/wt-disown.sh <worktree-abs-path>(best-effort, idempotent; the owner is provably dead, which the tool accepts without--force). Label before disown — the label is the outward marker (Step 4's ordering rule), and a disown failure still leaves a labeled issue a human can force-release. Never label analive-owner orunknown-owner worktree's issue — a live sibling's active issue is not stalled, andunknowncannot rule that out. The label is read downstream as ownership was released —skills/start/SKILL.mdoffers the user a force-takeover on the strength of it — so it belongs only on a candidate this path is also releasing. The ordering above accepts a narrow window where the label lands and the disown then fails; that is recoverable (a human force-releases) in a way that labeling a still-owned worktree is not. - Queued merge: if
Clean tree and no resumable worktree → proceed to Step 2.
Step 2: Pick — dispatch /next
Targeted mode: skip this step entirely — the invocation's issue ID is the pick, already validated and label-gated in Arguments. Proceed to Step 3.
Fleet deadline gate. Before dispatching /next, check <main-checkout>/tmp/fleet-deadline.json — written by /fleet-launch when the fleet was given a time budget (or by /fleet-stop), absent on most runs. Read deadline_epoch (jq -r '.deadline_epoch // empty'; unreadable or non-numeric → ignore the file and note that in the summary sentence — a malformed marker must not end a run). If date +%s ≥ deadline_epoch, the fleet's time budget is spent: do not pick. Set status: "drained" (with reason) and emit NO-CANDIDATES: fleet deadline reached (<deadline field>) — <shipped>/<canceled>/<skipped>/<failed> this run. Delete tmp/fleet-deadline.json (or re-run /fleet-launch) and delete tmp/auto-state-<runKey>.json to run again. — under self-paced /loop, ScheduleWakeup(stop: true) as with any NO-CANDIDATES. The gate sits here, after Step 1, deliberately: preflight still finishes in-flight work first, so a past-deadline session completes what it started and only declines new work — and targeted mode skips this step, so /auto <ISSUE-ID> ignores the deadline by construction (typing an ID is the human override). This gate is also the deadline's ONLY point of contact with the run: once an issue is picked, the marker is inert until the next pick. No mid-issue time-remaining arithmetic, no "will the gates fit" estimation, no parking, and no compressing or cutting gates to fit the clock — the in-flight issue runs its full pipeline however far past the deadline that lands, because overrun is what every wind-down consumer budgets for (/fleet-launch and /fleet-stop both document in-flight issues running to completion). Measured 2026-08-13 (BF-1108): a session estimated its remaining gates at 13–18 min against ~10 and abandoned a shipping-ready 12-hour cycle, trading ~8 minutes of overrun for a full re-entry cycle plus a human intervention. Never delete the marker from this skill — sibling sessions still mid-issue need to see it at their own next pick. Backed by hooks/auto-deadline-gate.sh, which refuses a next-candidates.sh invocation from a /loop /auto session once deadline_epoch has passed — because this gate is prose and prose lost: on the 2026-08-18 fleet, session 9c0e0a3b read the marker on all five of its earlier iterations and then skipped this step entirely at 07:51 CDT, 62 minutes past the deadline, picking BF-997 and shipping it at 10:09 for 3h20m of unbudgeted burn while its two siblings drained correctly. The hook fires only inside a /loop /auto session, so interactive /next, /auto-prep, /fleet-status and /fleet-retro — which run byte-identical commands — are unaffected, and targeted mode stays exempt by the same rule that exempts it here.
Fleet headroom gate — park between issues rather than die inside one. Fleet mode only (the deadline file exists), and it governs every pick this step makes, the hot handoff below included. After the deadline gate passes, run ~/.claude/scripts/fleet-headroom.sh. Exit 0 (PICK-OK) → proceed. Exit 2 (THROTTLE) → the account's trailing-5h output burn is within one issue's cost of the calibrated 5h ceiling, so a pick now would likely be KILLED mid-issue by the limit — do not pick. This is a pause, not an outcome: touch no status, emit no terminal tag; end the iteration as an AUTO-CONTINUE no-pick whose summary sentence is the probe's one-line output, and under self-paced /loop arm ScheduleWakeup (delaySeconds 1800, noop true, reason carrying the trailing/ceiling numbers) so the next tick re-probes after ~30 min of window roll-off. Why parking wins: a limit met BETWEEN issues costs minutes — the parked wakeup fires after the reset and the loop resumes itself — while a limit met MID-issue kills the turn with no wakeup pending and no Stop hook, leaving the session dead until a human attaches (measured 2026-08-17: 25.2 session-hours idle past the reset plus ~2 keeper-hours draining half-done issues). The probe FAILS OPEN (exit 0 + WARN) on any internal error — a broken probe must not idle a healthy fleet; auto-stall-watch is the backstop. The ceiling is calibrated, not queried: ~/.claude/telemetry/five-hour-ceiling.json, updated by /fleet-retro from each observed cutoff's trailing-5h meter, and account-specific — after an account switch expect one recalibrating cutoff, then update the file.
Hot handoff — a certified child the just-shipped issue filed preempts the ranking, once. When the immediately preceding iteration in THIS session shipped its issue and that run filed deferred children, and one of them is specified, unblocked, and carries no gating label (needs decision, human, solo), take the highest-priority such child as this iteration's pick instead of dispatching /next. The session still holds the parent run's loaded context — the measured repro, the census, the contract understanding — which is the most expensive artifact of that run and exactly what a cold pick discards (/quality-review's evidence-complete filings cite measurements only the filing session could reproduce). Hard bounds: consecutive iterations of the same session only (an intervening compaction does not disqualify; an intervening different pick does); one hop — a hot-picked child's own filings never qualify, so a run cannot chain refinements of its refinements (those enter ranking like any other filing); and the child takes Step 3's normal claim path, so losing it to a sibling is an ordinary SKIPPED-BLOCKED, not an error. The fleet-deadline gate above still applies, and everything downstream is unchanged — a hot pick is a full /full auto wt with its own plan, review, and gates.
Call Skill(skill: "next", args: "specified") — appending team:<KEYS> when this run has a team scope from Arguments (e.g. args: "specified team:BF"). It ranks unblocked candidates restricted to issues carrying the specified label — only certified specs ship unattended (standards/issue-spec.md; /prd and /spec are the primary certification paths, plus /reflect's auto-filed proposals and manual labeling). Without an explicit scope, team resolution follows /next: $LINEAR_TEAM when the project exports it, otherwise every team in the workspace — so one run drains all certified backlogs.
-
No candidates → confirm the empty pool before latching
drained— re-run the ranking once.next-candidates.shexits 0 on its no-workable-issues path, and it reaches that path both when the backlog is genuinely drained and when the underlyingissuesquery returned a 200-OK response carrying an empty or shortnodesarray — a shape the fetch loop accepts without error (it aborts only on an empty body, anerrorskey, or a non-zero CLI exit, all of which surface as exit 2 instead). Both cases print identical text and exit 0, so nothing in the result distinguishes them. Becausedrainedis sticky — Step 0 re-emits it verbatim on every later invocation, andNO-CANDIDATESschedules no further wakeup — one transient empty ends an overnight run permanently against an untouched backlog (observed: an empty first result, then 27+ candidates on an immediate identical re-run, with ~25specified+Plannedissues present in Linear throughout). So re-run the identicalSkill(skill: "next", …)dispatch once, back-to-back, and latchdrainedonly if BOTH runs come back empty; if the second returns candidates, take the top-ranked one as normal and note the discrepancy in the iteration tag's summary sentence. This re-run applies to this bullet only — the next bullet's "every candidate already attempted this run" case ranks a non-empty list against local run state and has nothing to re-confirm. It is also distinct from the transient-API rule above, which keys on an explicit error signal; here there is none, which is what makes the second run the only available evidence. Once both runs agree, setstatus: "drained"in the state file and emitNO-CANDIDATES: <team-scope> backlog drained of certified issues — <shipped>/<canceled>/<skipped>/<failed> this run. Run /spec to certify backlog issues (or /prd to seed new ones), then delete tmp/auto-state-<runKey>.json and re-invoke /auto.(<team-scope>= the scope/nextsearched, e.g.PLorPL+BF+MAR.) Under/loopself-pacing this ends the loop — callScheduleWakeup(stop: true)(also cancels the pending heartbeat) instead of scheduling another wakeup. -
Candidates exist → take the top-ranked one. Do not prompt the user to choose. Skip any candidate already in this run's
shipped,canceled,skipped, orfailedlists (shippedincluded: aDEFERRED-MERGEissue remains In Progress until the queue drains it, and re-picking it would race the queued merge). If every candidate is excluded that way, treat as no-candidates — including settingstatus: "drained"(the entry gate must hold under fixed-interval/loophere too) — but say so:NO-CANDIDATES: all remaining candidates were already attempted this run (<shipped>/<canceled>/<skipped>/<failed>). Delete tmp/auto-state-<runKey>.json to re-attempt.Also drop any candidate whose worktree is already live-owned by a sibling. If
<main-checkout>/.claude/worktrees/<issue-id-lowercased>exists, probe it with~/.claude/scripts/wt-owner.sh <worktree-abs-path>and drop the candidate whenOWNER_ALIVE=aliveandOWNER_IS_ME=0— the exact conditionstart-wt-create.shrefuses on (exit 4, "owned by another live session"). A sibling created and stamped that worktree before/nextranked but has not yet moved the issue to In Progress —start-wt-verify.shclaims at its stage 4, tens of seconds after the create, behind a digest pre-fetch and a baseline capture — so Linear-state ranking cannot see the claim, and Step 3's locked create would only refuse, costing the whole iteration for zero shipped work. The probe is local (git config + process liveness, no API call, ~0.4s), so run it on the top candidate and walk down the ranked list until one passes. Onlyalive+ not-mine drops:released,dead, andunknownare Step 1's resumption cases and the locked create reuses on all three. This is a pick-time drop, not a skip — do not add it toskipped, so the issue stays eligible later if the sibling dies. If this exclusion empties the list, end the iterationAUTO-CONTINUEwith no pick and do NOT setstatus: "drained"— unlike the shipped/canceled/skipped/failed exclusions above, a live sibling is transient, and latching the sticky terminal state on it would end the loop because the fleet was momentarily busy. This closes the create→claim half of the race only; a sibling that creates between this probe and Step 3's create still wins, and that loss still exitsSKIPPED-BLOCKEDper Parallel sessions.
Step 3: Ship — dispatch /full auto wt
Call Skill(skill: "full", args: "auto wt [pr] <ISSUE-ID>") — including pr only if the invocation carried the literal token (see Arguments); never add it here on your own judgment, and never to match repo history. /full auto runs /start auto (plan posted to Linear, no approval pause), /quality-review auto, and /finish auto, and terminates with exactly one lifecycle tag. Wait for that tag; do not narrate or race ahead. Under self-paced /loop, do NOT arm a heartbeat before this dispatch — ScheduleWakeup ends the turn, so arming here would skip /full entirely. A turn that stalls inside /full is recovered by hooks/auto-heartbeat.sh, which blocks the un-armed stop and re-drives the arming. The tag, when it arrives, is this step's input to Step 4 — never end the turn on it (the Nesting rule in standards/lifecycle-tags.md; /finish's closing messages say so too).
Step 4: Record outcome + emit the iteration tag
Maintain tmp/auto-state-<runKey>.json (Step 0's pinned path) in the project. Shape:
{
"pid": 50330,
"pidStart": "Tue Jul 21 14:46:01 2026",
"status": "active",
"reason": "",
"shipped": ["UI-3"],
"canceled": ["UI-9"],
"skipped": ["UI-7"],
"failed": ["UI-5"],
"consecutiveFailures": 0,
"reviewBlocks": 0
}
A state file predating reviewBlocks reads as reviewBlocks: 0; one predating canceled reads as canceled: []. canceled is its own list, not a shipped variant — the 2026-08-02 fleet recorded 4 cancellations in shipped[] (per this table's old rule) and its own tally overstated 29 ships vs 25, a discrepancy every retro then has to re-derive from git and Linear.
pid/pidStart are the GC liveness anchor (Step 0's HARNESS_PID/HARNESS_PID_START): written at creation and refreshed by Step 0 when a session-id-keyed file's harness restarted mid-run; on a pid-keyed fallback file a mismatch means PID recycling and Step 0 recreates the file empty instead. In a fleet this pair is shared, not per-session — session-identity.sh walks to the fleet root under claude agents — so it identifies the fleet's liveness, never this session's. runKey (the filename) is the per-session identity; pid is only ever a coarse "is anything still running" hint, which is why Step 0's GC gates on status first. Write your own resolved values and never copy pid out of a sibling's file: three of the 2026-08-04 fleet's four sessions did exactly that, and the one that happened to substitute a different live pid was the only ledger to survive the GC.
Read it (from the Step 0 pinned path), apply the outcome mapping below, write it back, then emit the tagged final line. The file — not conversation memory — is the source of truth across /loop iterations and context summarization. status transitions: active → halted (breaker or environment halt) / drained (no candidates); never back — a new run starts by deleting the file. Whichever step sets a terminal status also writes reason (the same one-line summary its tag carries) so Step 0's re-emission is verbatim, not improvised.
| Tag from Step 1/3 | Meaning | State update | Iteration tag |
| --- | --- | --- | --- |
| RELEASED / SHIPPED-MERGE / SHIPPED-PR / DEFERRED-MERGE | Shipped (DEFERRED-MERGE self-resolves via the merge queue) | append to shipped; consecutiveFailures = 0 | AUTO-CONTINUE |
| SKIPPED-BLOCKED | Not startable without a human; nothing claimed | append to skipped; counter unchanged | AUTO-CONTINUE |
| CANCELED | Work already done/unneeded; issue closed by /start | append to canceled (the backlog shrank, but nothing shipped — keeping it out of shipped is what lets the run's tally reconcile against git); consecutiveFailures = 0 | AUTO-CONTINUE |
| BLOCKED-ON-REVIEW | Review block — the quality gate stopped the ship and a human is flagged, but the pipeline itself worked (work preserved, verdict documented) | append to failed; reviewBlocks += 1; consecutiveFailures unchanged (neither incremented nor reset); post a Linear comment on the issue (via ~/.claude/scripts/linear-post.sh comment) stating the terminal tag, the reason, and that /auto is moving on; apply the stalled issue label (~/.claude/scripts/linear-add-label.sh <ISSUE-ID> stalled) — the issue stays In Progress after /auto abandons it, and the label is what makes it discoverable as needing attention (a plain In Progress issue is indistinguishable from live work) — then release the worktree's ownership stamp: ~/.claude/scripts/wt-disown.sh <main-checkout>/.claude/worktrees/<issue-id-lowercased> (best-effort; skip silently if the worktree no longer exists). Without the release, an abandoned issue stays unreachable to every other session for as long as this harness process lives — the stamp reads alive, the create gate refuses, and the resume path this row's own comment advertises is blocked. /start removes the label on resumption and mark-ready-for-release.sh on ship, so it never lingers past its meaning | AUTO-CONTINUE if reviewBlocks < 4, else set status: "halted" and emit the review-cap AUTO-HALTED |
| BLOCKED-ON-RECOVERY / ABANDONED / IN-PROGRESS / unrecognized or missing tag | Failure — something beyond the quality gate broke; the issue needs a human | append to failed; consecutiveFailures += 1; same Linear comment + stalled label + ownership release as the review-block row | AUTO-CONTINUE if consecutiveFailures < 2, else set status: "halted" and emit AUTO-HALTED |
Before applying the failure row, check the transient-API rule above — an iteration that died on API overload/rate-limit is retried, not recorded.
Human-gated stops are parks, not failures — and the label routes by WHAT the human must do. When a terminal tag's reason is something the pipeline cannot do itself — as opposed to an infrastructure or code failure — do NOT apply stalled; it is reserved for genuine pipeline failures, and a human wait is a park. Two shapes, two labels (standards/issue-spec.md documents the distinction): a decision the pipeline may not make (a product/design/business call surfaced mid-implementation, mid-review, or at merge time) parks under needs decision — the decision lands, the label clears, an agent ships it. A human-performed step — credentials/console/vendor-UI work, outreach, production data remediation, a sign-off — parks under human: no recorded answer hands it back, a person must act. The distinction is operational, not cosmetic — the keeper sweeps needs decision with /spec looking for a question to answer, and a capability park mislabeled needs decision wastes that pass while the real to-do stays invisible (BF-858: a Descope-console park carried needs decision through two re-pick cycles; its own park comment named "blocked on Descope console access", so the routing evidence was already in hand). The park mechanics are identical either way, in the failure row's ordering slots: post the comment naming the specific decision or the specific human step, apply the routed label (~/.claude/scripts/linear-add-label.sh <ISSUE-ID> <label>; exit 2 → linear-cli labels create "<label>" -t issue, retry once), move the issue back to Planned and unassign it (linear-cli issues update <ISSUE-ID> -s Planned, then issues assign <ISSUE-ID> with no user — the board must not show a dead session as active work), release the worktree stamp (wt-disown.sh, preserving the worktree and its commits for resumption), then write the state file. The tag's normal row still governs the counters and lists — only the outward marking changes. This converges with the pre-claim durable decline above on one end state: Planned + specified + the routed label, unassigned, worktree preserved when one exists; both labels hide from ranking (next-candidates.sh), and the comment plus worktree carry the partial-work signal (standards/issue-spec.md).
Failure-row ordering: outward markers first. The full order is comment → label → ownership release (wt-disown.sh) → state file. Post the Linear comment and apply the stalled label BEFORE writing the state file: an interruption between the two then leaves a flagged, documented issue that the state file merely hasn't counted yet — recoverable — whereas the reverse order leaves a counted failure with no trace on the issue itself (observed: a halted run's state file listed two failed issues that carried no failure comment, making the stall invisible from Linear). The disown sits between the label and the state write: an interruption after the label but before the disown leaves a flagged issue whose stamp a human (or /start's stalled-takeover mapping) can still force-release, whereas releasing before labeling could leave a released, unlabeled worktree invisible from Linear.
A durable decline marks the routed gate label; a transient one stays run-scoped. SKIPPED-BLOCKED covers two different things and only one should survive this run. Transient — a live sibling owns the worktree, the issue is In Progress under someone else, a blocker /next missed is still open — stays a run-scoped skipped entry: the condition clears on its own and the issue must stay eligible. Durable — the issue as written is not unattended-shippable (a product/design decision with no testable success criteria, work needing credentials/console/vendor access, or a human note on the issue saying it must not ship unattended) — is exactly /auto-prep Step 2's gated dispositions, routed by the same shape test as the mid-flight park above: decision-shaped (a do-not-ship-unattended note included) → needs decision; a human-performed step (credentials/console/vendor access is this, not a decision) → human. Every future session re-makes that same judgment unless it is recorded where /next can see it: skip lists are per-session (tmp/auto-state-<runKey>.json), next-candidates.sh ranks on labels/states/relations only, and a comment is invisible to it — so an unmarked decline gets ranked first again and a fresh session re-pays /start Step 0's worktree create + pnpm install (both of which run before sub-step 2's digest read surfaces the reason) to re-decline it, leaving an empty worktree the reaper keeps as "just-forked/unstarted". On a durable decline: post a comment naming the specific decision or access needed and what would clear it, then apply the routed label — ~/.claude/scripts/linear-add-label.sh <ISSUE-ID> 'needs decision' or ~/.claude/scripts/linear-add-label.sh <ISSUE-ID> human (exit 2 → the label may not exist yet: linear-cli labels create "<label>" -t issue, then retry once) — then remove the worktree if /start created one before declining (git worktree remove .claude/worktrees/<issue-id-lowercased> && git branch -D <branch>). The issue keeps specified — the spec is gated, not wrong (standards/issue-spec.md); next-candidates.sh hides both labels from every ranking, so no session re-picks it — needs decision until a human decides and clears the label (directly, or via /spec <ISSUE-ID>), human until the person acts (or /spec re-scopes the agent-shippable slice out). Comment before label, per the failure rows' outward-markers-first ordering. Never mark a transient skip, and never on a reason you cannot state in one sentence — when unsure, record the run-scoped skip only.
Iteration tags (per standards/lifecycle-tags.md — must be the LAST LLM-authored line, nothing after it):
AUTO-CONTINUE: <ISSUE-ID> <outcome> (<inherited tag>). <shipped>/<canceled>/<skipped>/<failed> this run; next /loop iteration proceeds.Under self-paced/loop, pair it with a 60sScheduleWakeup— see "Self-paced loop pacing" above.- Targeted mode: same outcome mapping and list/counter updates, but
statusnever transitions (drainedis impossible with no pick, and a failure does not sethalted— the breaker protects unattended recurrence, which a one-shot doesn't have; the incrementedconsecutiveFailuresstill counts toward any later bare run this session) and no wakeup is ever scheduled. The trailer replaces the loop clause:AUTO-CONTINUE: <ISSUE-ID> <outcome> (<inherited tag>). <shipped>/<canceled>/<skipped>/<failed> this run; targeted run complete. AUTO-CONTINUE: ... retrying in ~15m.— the transient-API retry variants defined in "Transient API failures" above.AUTO-HALTED: 2 consecutive failures (<ID> <tag>, <ID> <tag>) — likely systemic. See Linear comments on both issues; delete tmp/auto-state-<runKey>.json to start a fresh run.Under/loop, ending onAUTO-HALTEDorNO-CANDIDATESmeans the loop is over until a human intervenes: under self-paced/loop, callScheduleWakeup(stop: true)— it ends the loop and cancels the pending heartbeat; under fixed-interval/loop, schedule nothing (Step 0's sticky state absorbs re-fires).AUTO-HALTED: 4 review-blocked issues this run (<ID>, <ID>, <ID>, <ID>) — a human needs to work the review queue (each is labeled stalled with a Linear comment); delete tmp/auto-state-<runKey>.json to start a fresh run.— the review-cap variant; same no-further-wakeup rule.
Failure policy (why these defaults)
- Skip, don't stall: a single failed issue gets a Linear comment and preserved state (
/finish autonever overrides its verdict gate;/start autonever reassigns/reopens); the loop tries the next candidate. One flaky issue must not end an overnight run. One documented exception: a failed issue that leaves the main checkout dirty (Step 1 check 1'sfailed-list branch) halts the run — there is no way to start the next candidate over someone's uncommitted failure without either shipping it or destroying it, and/autodoes neither. - Circuit breaker at 2: two consecutive failures usually mean something systemic (broken main, dead service, exhausted auth) — more unattended attempts just burn tokens and litter Linear. Any successful ship resets the counter; Step 0 makes the halt sticky across re-invocations.
- Review blocks are exempt from the consecutive breaker:
BLOCKED-ON-REVIEWis the quality gate succeeding — work preserved, verdict documented, human flagged via thestalledlabel. Two in a row on distinct issues is normal on a hard backlog, not evidence of systemic breakage (an observed halt's own reason conceded both blocks were "genuine quality-review blocks with real open items" while still calling them "likely systemic"). They still count as failures everywhere else — recorded infailed, excluded from re-pick, comment + label posted. What bounds them is the review-block cap: 4 per run, total not consecutive, never reset by ships — capping both token burn under a systemically-broken reviewer and the pile of preserved-but-unmerged worktrees accumulating merge drift while they wait for a human. - Transient API errors are exempt: infrastructure recovers on its own — retry on a delay (15-minute failsafe cadence), never count it, never halt for it — except a quota whose stated reset is materially beyond
ScheduleWakeup's 3600s ceiling, which is an environment halt (see "Transient API failures are never failures"). - Conservative everywhere: every underlying
autodefault chooses abort/preserve over override/guess. The worst acceptable outcome of an unattended run is "nothing happened and Linear says why" — never "something wrong shipped."
Error Handling
Skill(next)errors (non-API: auth, missing dep) → setstatus: "halted", emitAUTO-HALTED: /next failed (<first error line>) — fix, delete tmp/auto-state-<runKey>.json, and re-invoke.Do not count as an issue failure. (API overload/rate-limit → transient rule: retry instead.)tmp/auto-state-<runKey>.jsonunreadable/corrupt → recreate it empty (status: "active") and note the reset in the iteration tag line's summary sentence.linear-cliunauthenticated → setstatus: "halted", emitAUTO-HALTED: linear-cli unauthenticated — run linear-cli auth oauth, delete tmp/auto-state-<runKey>.json, and re-invoke /auto.