stella self-driving

Commands that control the self-driving loop — how much work to do, what got done, which check runs next, and how duplicate issues are caught.

Run the loop that keeps finding and fixing problems on its own: plan the work, run a cycle, check the code, and watch for the next change.

Synopsis

stella self-driving surface [--format <text|json>]
stella self-driving plan [--explain]
stella self-driving state [--dry-streak | --format <text|json>]
stella self-driving cycle begin
stella self-driving cycle end --cycle <N> [--fixed N] [--filed N] [--new N] [--bench S] [--gate S] [--prs S] [--tier S] [--minutes N] [--outcome ok|resource-fail] [--lens-tool S]
stella self-driving watch
stella self-driving metrics
stella self-driving aperture (--current | --advance | --reset | --list)
stella self-driving seen (--digest TEXT… | --new DIGEST… | --add DIGEST… | --count)
stella self-driving calibrate (--ok | --resource-fail | --show)
stella self-driving queue [--limit N] [--format <text|json>]
stella self-driving sweep (regress | meta) [--dry-run] [--format <text|json>]
stella self-driving file --title TEXT [--body TEXT] [--label L…] [--format <text|json>]
stella self-driving work --issue KEY [--spend-limit USD] [--format <text|json>]
stella self-driving drive [--max-issues N] [--no-review] [--poll-secs N] [--backlog] [--dry-run] [--parallel N]
stella self-driving stop [--root DIR]
stella self-driving stats [--format <text|json>]
stella self-driving triage --issue KEY [--dry-run] [--format <text|json>]
stella self-driving close --issue KEY (--completed | --not-planned REASON | --duplicate-of KEY | --partial DONE --remaining KEY…) [--pr KEY | --commit SHA] [--per CITATION]
stella self-driving deliver open --issue KEY --branch B --title T
stella self-driving deliver observe --pr N [--format <text|json>]
stella self-driving deliver next --pr N [--fixes N] [--rebases N] [--no-review] [--format <text|json>]
stella self-driving deliver merge --pr N [--fixes N] [--rebases N] [--no-review]
stella self-driving run (start | end [--status S] [--reason S] | cancel [reason] | list | stamp-cycle-pid [PID])
stella self-driving runs
stella self-driving phase <name>

What it does

self-driving runs in a cycle: fix a batch of problems, check what's left, file what it can't fix as issues, compare results against a baseline, ship changes, and start over. The loop never stops. When one audit check runs out of new findings, the next one opens. When every check is dry, the loop drops into a low-cost watch mode and wakes up when something changes.

This command handles the deterministic part of the loop: everything a computer can decide with fixed rules instead of guesses. Because these decisions never need a model, the model driving the loop can never get them subtly wrong. Deciding which problems to fix, and what a finding means, is still left to the agent. The /self-driving slash commands call these same functions, and the scripts/self-driving.sh script uses this command for the same tasks.

stella stores this state at ~/.stella/self-driving/<slug>/. The slug comes from your repository's remote URL, so every worktree of the same repository shares one ledger, one seen-list, and one calibration. The Observatory reads these same files, but never changes them. Everything works offline and needs no API key — the exceptions are queue, plan, and watch, which read your issue queue through gh (and still work, just with less information, when gh isn't available).

The verbs

surface

stella self-driving surface
stella self-driving surface --format json

Prints every command a driver outside the binary is allowed to call on this build, and the shape each one writes to stdout.

Self-driving controls stella from the outside, the same way scripts/self-driving.sh does. A driver script reads this list once, when it starts. If the list doesn't match what the script expects, the script can refuse to run instead of failing partway through a cycle with an unrecognized subcommand error.

stella checks this list against the real program in both directions. If a command is added to the CLI but missing from this list, that's an error. If this list names a command that doesn't exist, that's also an error. This keeps the list matching the program exactly, instead of slowly falling out of date.

plan

plan looks at three things: supply (what your machine can handle right now), demand (what the issue queue needs), and calibration (what past cycles proved your machine can survive). It combines these into a tier and a set of settings, printed as SELF_DRIVING_* shell variables so you can't mistype them. Use --explain to see the reasoning instead of just the settings — every tier decision comes with a reason, because a governor that can't explain itself gets overridden and ignored.

Each probe stella uses (cores, load, memory, disk, battery, and contention) checks for a SELF_DRIVING_PROBE_* environment variable first, before reading your actual machine. This lets test suites, or you, correct a probe's reading if a machine is being read wrong.

cycle

cycle begin starts cycle N. It saves any abandoned run record and prints the cycle's context: SELF_DRIVING_CYCLE, how many dry cycles have happened in a row, the base commit, and the current audit lens with its tool. cycle end adds a record to the ledger, tells the controller what happened, and moves to the next audit lens once the current one has found nothing new for two cycles in a row.

--outcome is the only thing the controller looks at. ok means the cycle finished within its resource limits. resource-fail means something ran out, like a killed compiler process, an out-of-memory error, or running out of disk space. A failing CI check is not a resource failure — that means the batch of work was wrong, not that it was too big.

aperture

"No more defects" always means no more defects for the current lens, never for the whole codebase. The ladder of lenses is rubric, properties, invariants, concurrency, performance, supply-chain, security, docs, and soak, and each one checks something different. --list shows each lens with the actual command behind it, or the label model-only if there's no tool for it yet, so you always know whether a lens does real work. The performance and soak lenses cost real money to run, so they only open on the governor's heaviest tier.

Once every lens has been checked, the loop drops into watch mode. It never exits.

seen

stella tells findings apart by their content, not by issue number. --digest cleans up the text (lowercase, collapsed whitespace, line numbers removed) and hashes it, so the same problem is recognized as one finding even after unrelated edits move it twelve lines down. --new shows which digests haven't been seen before. --add saves new digests to the seen list. A cycle counts as dry when it finds zero new problems — fixing problems doesn't make a cycle dry, finding nothing does.

calibrate

After a clean cycle, the batch size grows by 2. After three clean cycles in a row, stella also adds one more parallel worktree. After a resource failure, the batch size is cut in half and stella drops back to running one worktree at a time. This is the only place these limits change, and they change based on real evidence — the same command works correctly on a small 16 GiB laptop and a large 123 GiB machine, without you setting any thresholds by hand.

watch

watch checks for anything that would undo the last clean sweep: new commits on main, new problems filed, or a red CI run. If any of these happened, the audit lens reopens at rubric and the command exits with code 0 (WAKE). If nothing changed, it exits with code 1 (SLEEP), so a driver script can loop on this exit code and spend nothing while waiting.

run, runs, phase

A run covers many cycles: one /loop session, or one daemon's whole lifetime. It's the unit you start, stop, and look into. Each change of state is added to runs.jsonl; when reading this file, the last entry for a given run_id wins. A separate file, run.json, always points to the current phase and the last heartbeat. If a run's last record says running but its heartbeat has gone stale, stella reports it as crashed — only a reader can tell this, because the process that would have updated the heartbeat is gone.

metrics, state, queue

metrics turns the ledger into evidence about how the loop itself is doing, flagging specific problems: STUCK, STARVED, NOISY, and FRAGILE. state shows the current counters and the last five cycles. queue ranks open problems by priority — P0, then P1, then P2, then untriaged, oldest first within each rank. Feature requests are left out, because this loop only closes defects. Ranking reads up to 1,000 open issues, which has to be more than your repository has: your tracker hands back the newest issues first, and the priority order is applied afterwards to whatever arrived. If the read stops before the end of your backlog, an old P0 never reaches the ranker at all.

sweep

stella self-driving sweep regress --dry-run
stella self-driving sweep meta --format json

The ranked queue runs dry. Two other sources of work do not, and sweep draws from one of them by hand.

sweep regress re-checks every fix the loop has closed. The loop writes down what each closure cited and whether that change was on the base branch at the time. This asks the same question again. A change that was there and is gone was taken back out — by a revert, a force push, or a rebase that dropped it — and the defect it fixed is back. That is a fact rather than a guess, so it is filed as a fresh defect.

sweep meta folds the loop's own cycle ledger and reports what its signals say about the loop: a ceiling stuck low, a lens that keeps finding nothing, a run that files far more than it fixes.

--dry-run prints what would be filed and writes nothing to your tracker. Run it that way first. Both sources ship switched off in stella.toml, and this is how you see what one would do before you switch it on.

Neither verb reads those switches. They say what the unattended loop may draw from on its own; you typing the verb have already chosen.

file

stella self-driving file turns a finding into an issue in your connected tracker. If something's wrong with it, it refuses to file the issue instead of filing a bad one.

Two checks happen before anything reaches the tracker. First, if a finding's digest is already in the seen list, it won't be filed again — this uses the same text-cleanup rule the aperture ladder's dry streak relies on, so the same problem reported at a different line number still counts as one finding. Second, the draft must match your workspace's issue format.

This second check exists to stop a feedback loop. stella's ranker treats any issue carrying a bug or triage label as a defect. GitHub's own triage workflow adds a triage label to any issue with no type label, and issues opened through the API start with no labels at all. Without this check, an unlabeled filing would come back next cycle as an untriaged defect the loop then has to triage itself — the loop would be manufacturing its own backlog, and its progress numbers would quietly include work it created for itself.

stella learns your issue format instead of guessing it: it reads .stella/issues/convention.json if your workspace has saved one, or otherwise figures it out from the automation that enforces it. If nothing can be found, the loop proposes a format and files nothing under it until a human accepts — it can suggest any format, but it can't approve its own suggestion. If a filing is refused, stella lists every problem with it at once and says where the format came from, so you know both what went wrong and what rule it broke.

work

stella self-driving work --issue KEY runs one issue through stella's normal turn loop, in its own isolated worktree, and reports what actually changed.

It starts a real stella run, rather than running a turn internally. Self-driving controls stella from the outside, so each unit of work gets exactly what a normal stella run gets on your machine — the same plugins, nothing extra. This matters: with no verification plugin installed, a completed work unit only means the turn finished, not that the change was proven correct. What actually makes an automatic merge safe is the CI check in deliver, not this step.

Issue text is never a command: anyone who can file an issue could write anything in it, so its text always reaches the prompt as a quote, with no authority to instruct the agent. The quote marker is generated from the issue's own content rather than fixed in advance, so text inside the issue can't fake the end of the quote and pretend to speak as you.

The outcome is measured from the actual files, never from what the turn says. If a turn exits cleanly and claims success but changes nothing, stella reports no_change and releases the worktree. This matters exactly when a turn's own summary and the real result disagree — a loop that trusted the summary instead would open empty pull requests.

Worktrees for this command live under .stella/private/self-driving/, on self-driving/ branches. This is outside the fleet's namespace, because stella fleet gc cleans up by namespace, and it should never be allowed to delete checkouts it didn't create.

By default, stella does the coding work itself. You can point it at Claude Code instead:

[self_driving.worker]
kind = "claude"
command = "claude"
model = "opus"       # optional
max_turns = 40       # optional limit per issue
dangerously_skip_permissions = false

Claude runs as claude -p --output-format json inside the same isolated worktree, so your normal project and user settings still apply. Skipping permission prompts is a separate setting you have to turn on yourself, because it lets the worker write files and run commands without asking first.

A Claude worker will not accept --spend-limit. Claude does not report what a run cost in the form stella measures, so stella cannot hold it to a dollar cap. Use max_turns, or your provider's own spending controls, instead.

drive

stella self-driving drive runs on its own, without you watching it. It keeps asking what to do next, does it, and asks again: claim the next issue from the ranked queue, run it through the turn loop, open a pull request, watch CI, and merge when it's ready.

Claiming an issue posts a comment on it saying who claimed it and for how long, and a second comment when the claim is given back. This is a courtesy for a human deciding what to hand out next, or for a session on a second clone — the comment can never decide who owns an issue, since GitHub has no way to check-and-set a claim. The workspace's own lease, in .stella/private/fleet.db, is what actually decides that; stella fleet claims --all shows it live. A failure to post — no gh, no login, a rate limit — never stops the loop from working the issue.

When it hits a block, it parks and keeps checking — it does not exit. Nothing stays "stuck" on purpose: every block is recalculated from scratch each time, so as soon as its cause is gone, it stops being reported. If the command exited on a block instead, nothing could resume it automatically. You never need to tell this loop it can resume — raising a limit, restoring a permission, removing a stop flag, or merging an escalated pull request already says everything that needs to be said.

--max-issues limits one run so that it eventually stops. The loop itself never stops on its own, which is why this limit is a flag you set, not a fixed number. When the loop hits this limit, it's reported as reached the bound, not as finished.

--backlog changes where the work comes from. The default generator takes only ranked defects — issues triage classified as work for the loop. With --backlog, the loop drains the whole ready backlog instead, feature work included. An issue is ready when it carries the status:ready label, or when every Blocked by: #N line in its body names an issue that is already closed. The convention is one greppable line per blocker, leading the line — Blocked by: #12, and several may share a line: Blocked by: #12, #13. Closing a blocker is the whole gesture: the dependent issue becomes ready on the loop's next queue read, with nobody editing anything. Each delivered issue is recorded as a cycle in the ledger, so state and metrics count delivery the same way they count audit cycles.

A tracking issue never counts as ready, whatever its blockers say — an epic whose body is a checklist of other issues is bookkeeping, not something the loop can fix, and claiming it the moment its last child closes means re-doing work a child issue already merged. epic is skipped by default; set self_driving.container_labels in stella.toml to name a different set (an operator's own word for a tracking issue) or a larger one. Declaring the list replaces the built-in default rather than adding to it, the same rule self_driving.triage's lists already follow.

--dry-run prints the issue the loop would take next — the generator, the queue depth, the pick, and the branch prefix it would cut — and then stops. It reads the tracker and changes nothing: no claim, no branch, no label, no session record.

--parallel N (with --backlog) works N ready issues at once instead of one after another. N defaults to the governor's number: what the machine probes and the learned calibration say this box can host right now. So a wave sizes itself to the machine, with nobody choosing a width. The ready issues go out as a single-wave fleet plan. Each worker gets its own worktree and holds its own issue lease, mirrored on GitHub the same way, so a second loop on the same repository defers instead of doubling up. The run's spend cap is divided across the width before any worker starts. At --parallel 1 the loop runs exactly the serial path described above.

If the machine asks for a step this build can't do, like sweeping an audit lens or curating a proposal, stella reports it and stops the loop, instead of silently skipping it. A driver that silently skipped steps would always look healthy, even when it wasn't doing anything.

stop

stella self-driving stop asks a running loop to pause. It writes a flag file that drive checks on every poll, and prints the path of the file it wrote.

The loop pauses at its next natural stopping point, between issues or between turns, never in the middle of one. A turn stopped mid-way would leave behind a worktree, a branch, and maybe a pull request that nothing has recorded, which someone would then have to clean up by hand.

The stop request isn't permanent. Deleting the flag file starts the loop working again, with no other input needed. This works the same way as every other block: the machine checks from scratch each time, so once the cause is gone, it stops appearing on the very next poll. There's no resume command, because deleting the file is the whole operation.

--root names the loop's state directory, in case you need to stop a loop that isn't rooted in this workspace — a second clone, or a state root shared across machines. Without --root, the flag is written to this workspace's own root, the same one the Observatory reads.

Asking a loop to stop when it's already stopping is not an error. The output tells you which case happened, because knowing it was "already asked" tells you the stop flag isn't why the loop is still working.

stats

stella self-driving stats shows what a session has done so far: issues claimed, attempted, changed, failed, escalated, parked, and deferred; issues created, refused, and marked as duplicates; issues closed, broken down by how they were closed; pull requests opened, merged, and escalated; fixes, rebases, and time spent waiting on a broken base branch; reflections, memories, and proposals written; and turns run, including turns stopped by their limit.

Escalated and parked are different news. An escalated issue is on a cooldown: the loop could not finish it this time, wrote down why, and will take it again by itself once enough time has passed. Nobody has to remove the agent-escalated label. A parked issue has used up its attempts, and the loop will never take it again — that one is waiting on a person. A break in the machine (a stale checkout, a provider outage, a failed install) comes back in minutes; work the loop genuinely could not do waits hours. Set all three numbers under [self_driving.escalation] in stella.toml.

stella writes these stats after every step, not just at the end. Since the loop never stops on its own, a report written only on exit would never get written at all — the point is a record you can read while the loop is still running. Each write is atomic, so a dashboard reading the file mid-write always sees the last complete version, never a half-written one.

Audit records

When the store refuses a write, the turn keeps going and the record of that turn does not. stella counts every audit write it gives up on and adds one line for them:

record
  3 audit records incomplete this run — a store write was refused; the turn's own warning names the SQLite code and the file

The line appears only when the count is above zero. The turn that lost the write also warns at the time, and that warning names the database file, the SQLite result code (DatabaseBusy, ReadOnly, Full), and whether stella asked again. A busy or locked database is retried a few times, with a growing pause between tries, before a write counts as lost — so a turn that only had to wait its turn still writes its record.

A turn runs in a child process, so it reports its own count in the turn summary and the loop adds that to the session's.

Ratios

A raw count only tells you how busy the loop was, which always looks good. The ratios below tell you whether the work was worth it, and each can look bad for a real reason:

  • inflation — issues created for every issue closed. If this stays above 1.00, the loop is losing ground: it's filing new issues faster than it's finishing them. This is a known risk that no safeguard fully prevents, so it's shown here on the dashboard where you'll see it right away.
  • attempt yield — the share of attempts that actually changed something.
  • merge rate — the share of opened pull requests that got merged.

If a ratio has no denominator, stella prints -- instead of 0.00. A ratio of zero over zero isn't a small number, it's undefined, and showing 0.00 would suggest something false. stella also checks that the three closure kinds add up to the total, and reports it if they don't — a dashboard showing a total that doesn't match its own breakdown is worse than showing neither, because people tend to trust whichever number they see first.

triage

stella self-driving triage --issue KEY updates one issue to match your workspace's issue format.

The loop manages its own backlog, so it can classify an issue that arrived unclassified, but its authority is limited. It removes anything the format reserves, since that's mechanical and needs no judgment. It reports what still needs a decision, instead of guessing: choosing between bug and feature, or between P0 and P2, requires reading and understanding the issue. If the loop guessed, it would be inventing the very classification its queue ranking depends on, which would make the ranking measure the loop's own guesses instead of real priority.

The same rule applies in both directions. conform decides whether a new issue can be filed; repair decides what's missing from an existing one. Both the loop's own filings and the backlog it inherited are held to the same standard.

close

stella self-driving close --issue KEY closes an issue and records proof of why. There are four kinds of closure, mapping onto the three close reasons most trackers offer.

Every kind of closure needs a citation, and the citations aren't interchangeable.

--completed closes an issue as done. It must cite the change that fixed it — --pr KEY, or --commit SHA for the rare case where a fix goes directly onto the branch without a pull request. You can't cite a document here: a document is not a change.

--not-planned REASON closes an issue as not planned. It must cite the decision behind it with --per, using either a document id (like doc:backlog-self-driving) or a context-record id. A reason with no cited authority is just an opinion pretending to be a decision, and the next person to hit the same problem won't know who decided this or what would change it. You can't cite a pull request here either, for the opposite reason: a change is not a decision.

--duplicate-of KEY closes an issue as a duplicate and names the issue it duplicates. Without this, anyone reading the closure would go looking for an original they can't find.

--partial DONE --remaining KEY… is the most important one. "Mostly done" is how most real work actually ends up, and it's tempting to round that up to "done." stella refuses this outright unless the remaining work already exists as its own tracked issues. The closing comment links to them, so the backlog only shrinks by the amount of work actually finished.

All of these checks happen before anything reaches the tracker, so a closure that's missing its citation can never half-happen.

Tracker support

The [issues] section in stella.toml says which issue tracker is active, and where its manifest file lives. That manifest file, .stella/issues/<provider>.toml by default, says how that tracker works: what marks an issue open or closed, how each resolution is spelled, and what its fields are called.

This is split on purpose. stella.toml is where you look for anything you can configure about stella, so which tracker to use belongs there. A specific vendor's exact wording doesn't belong there, and if a workspace uses two trackers, there needs to be somewhere to put the second one's settings.

stella only recognizes three resolutions: completed, not_planned, and duplicate — these are GitHub's own close reasons. If your tracker offers twenty resolutions, it maps to these three and the rest pass through as-is. You don't need to configure anything to use stella with GitHub: with no config, no [issues] section, and no manifest file, everything works with sensible defaults.

deliver

stella self-driving deliver handles the pull-request cycle: open, check, decide, and merge.

Deciding what to do never calls a model. What to do with an open pull request is a straightforward calculation based on observed facts, not a judgment call, so the decision lives in a pure state machine and this command only reads the pull request's state and does whatever the calculation says. merge recalculates the decision fresh, instead of trusting that next was already run — a pull request's state can change between calls, and merging based on stale information is exactly what the "mergeability not yet computed" check exists to prevent.

base_ci checks the base branch separately, on purpose. A test failure that also happens on the base branch isn't this pull request's fault, and treating it as one wastes every cycle chasing somebody else's broken build. So observe doesn't just ask "is main red?" It compares the same checks, by name. For every check that fails on the pull request, it looks at what that same check concluded on the base branch's head, and the base only counts as broken if the failures actually overlap. A blanket check would wrongly call a real regression "inherited" any time something unrelated was red, which is a much more costly mistake to make.

Closing an issue needs to be written twice. Closes #N goes both in the pull request body and as a commit trailer. A squash merge builds its final message from the commits, never from the body, while a rebase merge keeps the commits as-is. Writing it in only one place would silently fail depending on the merge type. The commit trailer is added by work, since that's the step that writes the commit.

A pull request opens as a draft, and stella takes it out of draft on its own once CI turns green — so a pull request that never passes CI never asks a person to review it. Merging waits for a human approval unless you pass --no-review. This default is deliberate: if unreviewed merges were the default, you'd have to remember to turn on the safe option instead of the other way around.

Files

PathPurpose
~/.stella/self-driving/<slug>/ledger.jsonlOne record per completed cycle
~/.stella/self-driving/<slug>/runs.jsonlRun state changes, added to over time and combined by readers
~/.stella/self-driving/<slug>/run.jsonThe live pointer: current phase, heartbeat, and process ids
~/.stella/self-driving/<slug>/seen.txtEvery finding digest ever triaged
~/.stella/self-driving/<slug>/calibration.jsonThe learned batch-size and worktree limits
~/.stella/self-driving/<slug>/apertureThe open audit lens

SELF_DRIVING_STATE_DIR moves this whole directory somewhere else (used by the test suite); STELLA_HOME moves it along with the rest of stella's home directory.