robot-army

Setup

Install it, give it a token, point it at a repository, and — if you want to watch it work before it can do anything — run it at an effect level where it cannot.

Running it

cd ~/GIT/robot-army
uv sync
uv run pytest                  # the suite must pass

mkdir -p ~/.config/robot-army
uv run robot-army example-config --output ~/.config/robot-army/config.toml
$EDITOR ~/.config/robot-army/config.toml     # see the configuration page
export ROBOT_ARMY_GITHUB_TOKEN=ghp_...       # a CLASSIC PAT; see below

uv run robot-army doctor       # run this first, every time
uv run robot-army onboard jantman/some-repo
uv run robot-army run

example-config writes a fully commented file with every option in it, at their defaults. Two values are actually yours: [github] author and [paths] repo_root. Everything else has a sensible default, and everything outward-facing is off. The configuration page explains each key.

Until the first onboard, doctor fails exactly one check, onboarded repositories. That is not a false alarm: an installation that may act in no repository can do nothing at all, and the same check is what tells you when a lost state database has taken every onboarding with it.

The token has to be a classic one

Use a classic personal access token with repo and read:project. Not a fine-grained token, and this is not a preference.

GitHub has an organisation-level Projects permission for fine-grained tokens and no account-level one. A user-owned board — anything at github.com/users/<you>/projects/N, which is what you get by clicking “New project” on your own account — therefore cannot be read by a fine-grained token however it is configured. There is no setting that fixes it; the permission does not exist. See community #156512.

Without read:project everything still works except board ordering, which is skipped with the reason recorded. doctor tells you which kind of token you are holding and what it is missing, so this is one line of output rather than an afternoon:

[ok]   project: demo token             classic token can read projects
[FAIL] project: demo token             this looks like a fine-grained token or GitHub App …

Adding a repository

onboard is the whole job. No file edit, no restart:

uv run robot-army onboard jantman/some-repo

It works out where the clone is — <repo_root>/<name>, one candidate, no searching — checks that the clone actually is that repository by reading its origin, shows me what it found, and asks. Onboard a repository while the daemon is running and it is polled on the next cycle.

[paths]
repo_root = "~/GIT"        # where clones live; a repo's default location is <repo_root>/<name>

[hooks]
post_create = [ { run = "uv sync", timeout = 120 } ]   # the steps every repo gets

The origin check is the part that earns its keep. Five of my 252 repositories have a clone of a different repository sitting at the derived path — ~/GIT/zoneminder is upstream’s clone, not my fork’s — and without the check those five would get a branch cut in someone else’s repository. They are refused, by name:

$ robot-army onboard jantman/zoneminder
refusing: the clone at /home/jantman/GIT/zoneminder is ZoneMinder/zoneminder,
          not jantman/zoneminder.
          The path was derived from [paths] repo_root. If your clone of
          jantman/zoneminder is elsewhere, set it explicitly:

              [repos."jantman/zoneminder"]
              path = "/where/it/actually/is"

A repository that is genuinely unusual gets a [repos.*] section of overrides — not a registration, and nothing needs one. See [repos.*] is for exceptions.

[github] include_owned and extra_repos decide what may be onboarded — any repository I own, plus any I list. They are a guard against a mistyped name, not a security boundary; the issue-author check is that one, and it cannot be disabled.

That check is enforced in one place, poll.evaluate, and every path that can put an item in the dispatch queue goes through it. retry is the one that used to not: it re-checked the repository’s conditions and returned the item to the queue carrying its stored, someone-else-authored body, while the confirmation in front of the button promised the opposite. It now re-reads the issue from GitHub and re-runs the same verdict, so an item blocked because somebody else wrote it stays blocked. A second comparison at dispatch, against the author recorded on the item, is a backstop rather than the gate.

The base ref comes from the repository

The base ref line says which branch new work will be cut from — and which branch the settings below it are read at. It is detected from the clone, by reading refs/remotes/origin/HEAD, and the line says where the answer came from:

base ref     : master   (detected from origin/HEAD)
base ref     : develop   ([repos."jantman/some-repo"] base_branch)
base ref     : main   ([worker] base_branch; the clone does not say which branch is its default)

It used to be [worker] base_branch, whose default is "main" — the only line on this screen nothing had looked at the repository to produce. Onboarding a master repository therefore read the committed settings at a ref that does not exist, said “no committed .claude/settings*.json at the base ref”, and recorded that nothing as approved (issue #150).

Four rungs, first one that answers wins: [repos."owner/name"] base_branch → what the clone says → [worker] base_branch → main. Detection beats [worker] base_branch on purpose; configuration.md has the why. Detection is a local ref read — no network, no token — so a clone whose remote has never been fetched simply falls through to the configured value, and the screen says that it did.

What the approval screen puts in front of you

Between the resolution lines and the prompt, onboard prints the full text of any .claude/settings*.json committed at the base branch tip:

committed tool-permission settings at the base ref:
  These are applied to a dispatched session WITHOUT asking. Read them.

  --- .claude/settings.json ---
  {"hooks": {"SessionStart": [{"hooks": [{"type": "command",
     "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.bmad-loop/hook.py SessionStart"}]}]}}

That is the control. A session dispatched into the repository honours those settings without asking, so a committed SessionStart hook runs a script from the repository the moment the session opens. The hash of what you approved is recorded, and dispatch blocks if it changes later, naming the files and pointing at onboard --reapprove.

It is read from git, at the base branch tip — not from the working tree — because that is what a freshly created worktree will contain.

This screen is real at every effect level, including plan. It did not used to be: the read behind it was simulated below local and answered “no such file” for every path, so the screen said no committed .claude/settings*.json at the base ref for every repository and an empty set of hashes was recorded as approved. Rehearsing onboarding at plan first — the natural instinct — was the one way to see nothing at all (issue #20).

If you onboarded anything while that was true, the recorded approval claims the repository commits no settings. Nothing backfills it, deliberately: an approval means a human read the file and said yes. The next dispatch blocks instead, names the files as added:, and onboard --reapprove shows you the real review.

The one thing onboarding warns about rather than refuses

If the repository uses Spec Kit and numbers its feature directories by scanning, the approval screen says so before it asks:

spec kit: this repository numbers feature directories by scanning
  feature_numbering is "sequential" in .specify/init-options.json.
  Two sessions running at once scan the same specs/ and cannot see each other's
  worktrees, so both can claim the same number. Nothing here prevents that.
  Set "feature_numbering": "timestamp" in that file to number by time instead.

/speckit-specify picks a number by scanning specs/ for the highest one already used — a scan of one worktree. With one worktree per issue, that scan cannot see a number a sibling worktree claimed ten minutes ago, so two concurrent sessions take the same one. It happened twice here: 012 and 014 each name two different features.

Nothing in this daemon can catch that, which is why this is a warning and not a check. The losing session’s claim exists only as untracked files in another worktree — not on a branch, not in a ref, not in anything git can be asked about. Widening a search from “this worktree” to “every ref in the repository” finds nothing and picks the same number. The only thing that closes the race is "feature_numbering": "timestamp" in the repository’s own .specify/init-options.json, which names directories by the second and cannot collide.

So it is said once, at the moment I am already reading a screen about a repository and deciding whether to trust it, and then never again. Onboarding is not blocked, the exit code does not change, and nothing is written into the repository. Ignoring it is a real choice: a duplicate prefix breaks no tooling, because Spec Kit resolves a feature by full path. What is lost is that “spec 012” stops being an unambiguous name for anything.

Two things it deliberately does not do. It does not warn about a repository that is not a Spec Kit project, whatever files are lying around in it — detection has to say yes first. And it does not report a .specify/init-options.json it could not parse as unsafe; that gets its own wording, because “this is set to scanning” and “I could not read this file” are different things to be told:

spec kit: the feature numbering could not be determined
  .specify/init-options.json: not a JSON object.
  If it does not say "timestamp", two sessions running at once can claim the
  same feature number. Set "feature_numbering": "timestamp" to be sure.

This is checked at onboarding only. A repository that installs Spec Kit afterwards is not warned until the next onboard --reapprove, which is a deliberate limit rather than an oversight — the alternative is a line about numbering on every dispatch, forever, for a problem this size.

Two things that are easy to overlook

doctor catches the failure that cost the most time during the spike: a kitty instance carrying CLAUDE_CODE_CHILD_SESSION in its environment silently disables transcript saving, producing sessions that look perfect, exit 0, and can never be resumed.

Trying it without consequences

Four graduated effect levels, enforced at the boundaries rather than at call sites:

Level Polls Worktrees Sessions GitHub writes Notifications The session is told to
plan real no no no no —
local real real no no no —
no-remote real real real no no commit, and not push
live real real real real real commit, push, open a PR

Cleanup follows the worktree row rather than the GitHub one: simulated at plan, real at local and above, because removing a worktree is a local effect. A notification leaves the machine, so it follows the GitHub row.

What the level does not reach

Every column but the last is about what robot-army does. The last one is different in kind, and the difference is worth being blunt about, because it cost me a surprise: the session a dispatch launches is a full Claude Code session running as me, with my gh credentials and my network, and no effect level constrains it. It cannot. Below live the daemon simulates its own comment — and at no-remote one of those sessions committed and pushed a branch to GitHub while it did (issue #32).

So below live the prompt now asks: the delivery block tells the session to commit its work on the branch and to leave it there — no push, no pull request, no comment on the issue, nothing to any remote — and says that an instruction above it asking for a push describes a live dispatch. At live it reads exactly as it always did. What a session is told has both.

Asking is not enforcing, and no wording here should be read as a boundary. A session that pushes anyway is not stopped. If that ever matters more than having a real interactive session with my own tooling, the answer is a credential-less environment or a sandbox, and that is a different program.

Reads are always real — a dry run that fakes its reads tells you nothing about the main thing you want to check. That covers polling and eligibility, and it covers the onboarding settings review above: nothing about approving a repository is reduced below live.

The one nuance is what “read” means at a boundary that also invents things. Asking what is committed in your clone has a true answer at every level, so it is answered truthfully. Asking whether the worktree a plan run pretended to create is dirty does not, so that is answered as-if. The rule is the subject of the question decides, not the verb, and it is written down at SimulatedVersionControl because the two times it was left implicit, it was got wrong.

uv run robot-army run --dry-run --once          # plan
uv run robot-army run --effect-level local      # debug a repo's preparation steps
uv run robot-army status --include-simulated
uv run robot-army purge-simulated

Simulated rows are excluded from every listing unless you ask for them, and are visibly marked when shown. Every listing that excludes them also says how many it withheld and how to see them — status (its counts, its listing and its anomaly block), cards, worktree list, anomalies and log — so none of them ever reports an empty system while holding rows back, and status in particular never prints a populated queue above a claim that there is no work.

--include-simulated is offered on those five and on nothing else. repos used to accept it and ignore it: onboarding inspects a real clone on disk and has no rehearsed path, so its table cannot hold a row the flag would hide, and a flag that silently does nothing is worse than no flag at all (issue #21). A test drives every verb that advertises it, in both spellings, and fails if the two agree.


Next: where work comes from.