rinman24/squadra

Deterministic supervisor + per-slice runner machinery for an AFK, board-driven Claude implementation fleet (ADR-0007).

★ 0Forks 0PythonGitHub ↗Compare

README

squadra logo

squadra

Ships every slice. Unattended.

Testing CI - Lint + Typecheck + Test Coverage
Package PyPI Latest Release
Meta License - MIT Last commit Commit activity

squadra is deterministic supervisor and per-slice runner machinery for an unattended, board-driven Claude implementation fleet. It runs the implementation phase (/tdd → /qa) across many vertical slices at once, hands-off, with board work-item state as the single source of truth at every tier. squadra speaks a provider-neutral 3-bucket Lifecycle (queued / active / done); a BoardAccess adapter translates that to a concrete board's native semantics at the boundary. Provider-neutral by design — Azure DevOps (the az CLI) ships today; GitHub and GitLab are next, tracked backlog adapters the same contract-test suite will validate. Every fleet process is stateless or short-lived and reconstructs its view from the board on each run.

squadra is the packaged, reusable extraction of a fleet originally built inside a private backend project. Its provider-neutral seam is ADR-0001 and its design note. The package ships the deterministic machinery + its tests + scaffolding — the agent-side skills (/afk-slice-runner, /tdd, /qa, /cleanup-merged-branches) are consumer-owned and live in the consuming repo. squadra scaffolds genericized templates for the ones it drives (squadra init), then invokes them only by skill name through claude: scaffolding the template is not owning it, so the runtime boundary is unchanged.

squadra operates on a target repository, identified by FLEET_HOME (the current working directory by default). The installed package is never the working repo; the shell glue it drives ships as package data and is resolved via importlib.resources.

Install

squadra is built with uv + Hatchling (PEP 621, src layout). It has no third-party runtime dependencies (pure standard library); tmux, git, the board provider's CLI (the az CLI for the ADO adapter that ships today), and the claude CLI must be available on the host that runs the fleet.

# From a clone (development):
uv sync                      # create .venv, install squadra + dev tools

# As a dependency of another project (consumer):
#   editable path dep while iterating, or a git+HTTPS pin once stable
uv pip install -e /path/to/squadra

A single unified squadra CLI is installed (the API / composition root):

Command Role
squadra init [--provider P] [--check] scaffold an annotated squadra.toml; --check validates it against the live board
squadra tick [--dry-run] run one supervisor tick in-process
squadra {start|stop|status|log} hands-on ticker control (shells to the packaged fleetctl.sh)
squadra slice {init|update|heartbeat|show} the per-slice status-file ops (used by the runner wrapper)

python -m squadra.supervisor (one tick) and python -m squadra.status (the status-file CLI) remain as internal module entry points. The deprecated squadra-status console script has been dropped — use squadra slice instead.

Configuration

squadra reads a squadra.toml in the target repo (the what — which board, which states, which skills) and layers it under environment + flag overrides. The precedence, lowest to highest:

built-in defaults  <  squadra.toml  <  FLEET_* env  <  CLI flag

squadra.toml describes the target (like a kubeconfig context) and defaults the how wherever it can. Schema:

Section / key Default Meaning
[board].provider ado ado | github | gitlab. Selects the BoardAccess adapter (registry in the CLI composition root). ADO ships today; GitHub/GitLab are tracked backlog adapters.
[board].base_branch main The branch a slice PR must complete against for finalize-eligibility.
[board].tag_prefix fleet: Configurable namespace for the fleet's tags; detection is prefix-based (startswith). The five suffixes are fixed (see Tag vocabulary).
[board].parent_scope_ids [] (whole project) Optional claim-scope filter — only slices under these parents are claimable. Supersedes the legacy FLEET_EPIC_IDS env, which is still honored.
[board.states].queued / .active / .done — Lists of the board's native state names mapped onto the three neutral Lifecycle buckets (many-native→one-neutral allowed). REQUIRED unless the provider is ADO-Basic, which defaults to ["To Do"] / ["Doing"] / ["Done"]. GitHub/GitLab statuses are user-defined, so they must be declared.
[pipeline].branch_template feat/slice-{id}-{slug} Slice branch naming. squadra owns the -a{attempt} retry suffix (fixed rule, not templated).
[pipeline].worktree_dir .claude/worktrees Where slice worktrees are created.
[pipeline].runner_skill /afk-slice-runner Skill the runner wrapper invokes per slice.
[pipeline].tdd_skill /tdd TDD skill name, threaded into the runner prompt.
[pipeline].qa_skill /qa QA skill name, threaded into the runner prompt.
[pipeline].cleanup_skill /cleanup-merged-branches Skill the finalize pass runs headlessly per merged branch.
[board]
provider         = "ado"          # ado | github | gitlab   (REQUIRED)
base_branch      = "main"
tag_prefix       = "fleet:"
parent_scope_ids = [105]          # optional; empty = whole project (was FLEET_EPIC_IDS)

[board.states]                    # REQUIRED unless provider is ADO-Basic; many-native→one allowed
queued = ["To Do"]
active = ["Doing"]
done   = ["Done"]

[pipeline]
branch_template = "feat/slice-{id}-{slug}"
worktree_dir    = ".claude/worktrees"
runner_skill    = "/afk-slice-runner"
tdd_skill       = "/tdd"
qa_skill        = "/qa"
cleanup_skill   = "/cleanup-merged-branches"

Operational and secret knobs stay env-only with the defaults in Constants: FLEET_MAX_RUNNERS, the intervals (FLEET_TICK_INTERVAL_SECONDS, FLEET_HEARTBEAT_INTERVAL_SECONDS, FLEET_STALENESS_THRESHOLD_SECONDS), FLEET_MAX_ATTEMPTS, FLEET_MODEL/FLEET_EFFORT, FLEET_HOME/FLEET_ROOT/FLEET_PYTHON, and the PAT. These are not in squadra.toml.

Safety is validate-against-board, not mandatory typing. validate_config() resolves the configured state names, tag prefix, and base branch against the live board — at startup of every tick and on squadra init --check — and fails loud on any mismatch (e.g. "configured active state 'Doing' not found among this project's states"). A typo can't silently strand or mis-claim slices.

Scaffolding (squadra init)

squadra init [--provider ado] makes adoption one command plus a few edits. It emits:

  • a complete, annotated squadra.toml — every key written with its default and provider taken from --provider; and
  • the genericized, consumer-owned runner-skill and cleanup-skill templates.

The skill templates are provider/repo-agnostic (a neutral lifecycle: claim-verify → worktree → seams → tdd → qa → park) with clearly-marked fill-in sections (e.g. ## Gates, shared-seam conventions) that work out of the box. squadra copies them out, then drives them only by skill name through claude — copying the template is not owning it, so the "machinery + tests + scaffolding" runtime boundary holds. squadra init --check runs validate_config() against the live board without writing anything.

The unified squadra init CLI surface lands with the PR2 core; the scaffolding engine itself ships on this branch.

Status file + heartbeat convention

Each in-flight slice has one status file — the micro view of its runner:

$FLEET_HOME/.claude/fleet/<issue-id>/status.json

The fleet root defaults under the target repo's .claude/fleet; on a bind-mounted checkout it survives container restart. Exclude it from the target repo's git (**/.claude/fleet/).

Schema

Field Type Meaning
issue_id int The slice's board work-item id
runner_id str Unique id of the runner attempt (e.g. runner-41-a1-…)
branch str Slice branch (feat/slice-<id>-<kebab>)
worktree str Absolute path of the slice's git worktree
pr_url str | null The slice's PR once opened
phase enum claiming → seams → tdd → qa → parked → done
parked_state enum | null needs-decision, qa-ready, awaiting-pr-approval, failed — non-null iff phase is parked
worker_roster list[str] Worker sub-agents the runner fanned out
started_at str ISO 8601 UTC, set at init
last_heartbeat str ISO 8601 UTC, stamped every heartbeat interval while the runner process is alive
attempt int 1-based attempt counter (transient failures retry up to 3)
last_error str | null Last recorded failure diagnostic

Concurrency contract

Two writers touch the file: the runner wrapper's deterministic heartbeat loop (liveness = process alive, independent of what the agent is doing) and the agent updating phase/parked_state/worker_roster at transitions. Every read-modify-write therefore happens under a sidecar flock (<issue-id>/.status.lock) and lands atomically via tmp-file + rename. Readers never see partial JSON; interleaved writers never lose fields.

CLI

Shell callers (the runner wrapper, skills) use the status CLI:

squadra slice init \
  --issue-id 41 --runner-id runner-41-a1 \
  --branch feat/slice-41-example --worktree "$FLEET_HOME/.claude/worktrees/feat+slice-41-example"

squadra slice update --issue-id 41 --phase tdd
squadra slice update --issue-id 41 \
  --phase parked --parked-state awaiting-pr-approval --pr-url <url>
squadra slice update --issue-id 41 --phase tdd --parked-state none
squadra slice heartbeat --issue-id 41
squadra slice show --issue-id 41

--fleet-root overrides the location (used by tests; defaults to $FLEET_HOME/.claude/fleet). python -m squadra.status … is equivalent.

Constants

All addendum constants live in squadra/constants.py and are env-tunable (read at process start — every fleet process is fresh per fire):

Constant Default Env override
Fleet home (target repo) current working directory FLEET_HOME
Fleet root $FLEET_HOME/.claude/fleet FLEET_ROOT
Interpreter for the fleet the supervisor's sys.executable (shell default python3) FLEET_PYTHON
Max parallel runners (the claim budget — 0 stops new claims only, it is not a safety lever) 2 FLEET_MAX_RUNNERS
Dry-run tick (plan + report, suppress every side effect) off FLEET_DRY_RUN
Heartbeat interval 60s FLEET_HEARTBEAT_INTERVAL_SECONDS
Staleness threshold 600s FLEET_STALENESS_THRESHOLD_SECONDS
Max attempts 3 FLEET_MAX_ATTEMPTS
Model claude-opus-4-8 FLEET_MODEL
Reasoning effort high FLEET_EFFORT

The compute tier (FLEET_MODEL / FLEET_EFFORT) is pinned in constants.py and applied as explicit claude --model … --effort … flags to every model-backed fleet call — the slice runner, the cleanup pass, and the auth probe (the probe pins --model only, since it does no reasoning). This is deliberate: a headless claude -p otherwise inherits whatever model an interactive session's settings.json happens to pin, so the fleet's tier would be an ambient side effect rather than a choice. Effort accepts the CLI levels low|medium|high|xhigh|max (model-dependent). To run hotter or cheaper, export FLEET_MODEL / FLEET_EFFORT — it flows through autostart → ticker → supervisor → runner panes by environment inheritance.

FLEET_PYTHON must point at an interpreter that has squadra installed; the supervisor injects its own sys.executable into each runner pane so the runner reaches squadra.* regardless of what python3 resolves to on PATH.

Tag vocabulary (parked sub-states are tags, not states — the ADO Basic process has only To Do / Doing / Done). The five suffixes — claimed, failed, needs-decision, qa-ready, awaiting-pr-approval — are fixed canonical vocabulary, carried under a configurable namespace prefix (default fleet:, set via [board].tag_prefix / FLEET_TAG_PREFIX), so the shipping defaults read fleet:claimed, fleet:failed, fleet:needs-decision, fleet:qa-ready, fleet:awaiting-pr-approval. Fleet-tag detection is prefix-based (startswith(prefix)), not a hardcoded literal set.

The neutral comment the fleet attaches at each transition is emitted by core as a structured event and rendered to the board's native markup at the adapter boundary (ADO → HTML, GitHub → Markdown) — core itself emits no markup.

Tuning path for the cap (addendum §1): raise as cores/headroom grow; back off on CPU saturation or 429s.

Slice runners

A runner is one short-lived, headless Claude session driving one slice. The supervisor launches each into its own per-slice ephemeral Docker compose project via SandboxAccess (ADR-0002 §5): the compose agent service's command is the deterministic wrapper (squadra/_scripts/runner-wrap.sh, resolved from the installed package and invoked as runner-wrap.sh <issue-id> <branch> [attempt]), so container lifecycle == agent lifecycle and docker inspect .State.ExitCode is the agent exit code.

The wrapper owns everything that must not depend on an LLM:

  • seeds status.json (init), records the runner.pid / pane-id / heartbeat.pid sidecars, and appends all output to <issue-id>/runner.log;
  • runs the heartbeat loop — last_heartbeat advances every heartbeat interval for exactly as long as the wrapper process lives, so liveness means process alive, independent of how long the agent's current tool call runs;
  • invokes the headless session, threading the configured skill names into the prompt: claude -p "<runner_skill> issue-id=… branch=… attempt=… tdd-skill=… qa-skill=…" --dangerously-skip-permissions --model "$FLEET_MODEL" --effort "$FLEET_EFFORT". Because the runner/tdd/qa skill names are config (not hardcoded), the runner skill no longer hardcodes /tdd,/qa — it runs whatever names it is handed;
  • backstops an unexpected death: a healthy runner always exits parked (or done); if the session exits in any other phase, the wrapper stamps parked_state=failed + last_error and propagates the non-zero exit.

The afk-slice-runner skill (in the consuming repo) is the agent side of the contract: verify the claim, enter the slice worktree, write the shared seams before any fan-out, execute the configured tdd then qa skills unchanged, update phase/pr_url/worker_roster at transitions, park with the matching fleet tag + comment, exit. Parked states are never a hung session — they are queryable board state plus the status file.

Runner wrapper env knobs: FLEET_HOME, FLEET_ROOT, FLEET_HEARTBEAT_INTERVAL_SECONDS, FLEET_MODEL, FLEET_EFFORT, FLEET_RUNNER_SKILL, FLEET_TDD_SKILL, FLEET_QA_SKILL (the supervisor injects these into the pane env; when any is unset — e.g. a manual run — the wrapper resolves the default from squadra.config / squadra.constants, the single source of truth, the same fallback pattern as FLEET_MODEL/FLEET_EFFORT/the interval), FLEET_PYTHON, FLEET_CLAUDE_CMD (stubbed in the hermetic tests).

Supervisor

squadra/supervisor.py is the deterministic, token-free tick: no LLM anywhere, so it cannot hallucinate a board mutation, and it is unit-tested against in-memory fakes (including a divergent GitHub-shaped fake, which catches any hardcoded native-state leak and proves the core is provider-blind). It speaks the neutral Lifecycle throughout; the BoardAccess adapter maps to native states at the boundary. Each tick runs three ordered passes under one lock — finalize → reap → claim — so cap accounting is fresh before anything new launches (addendum §5). The native state names below (To Do/Doing/Done) are the ADO-Basic mapping of the neutral queued/active/done Lifecycle buckets; under another provider the adapter substitutes that board's configured names.

  1. Serialize — take a non-blocking flock on <fleet-root>/supervisor.lock; a tick that cannot get the lock exits 0 without touching the board.
  2. Count inflight — Issues in Doing carrying fleet:claimed. A human's manually-moved Doing Issue is invisible to the fleet (no tag): never counted, never reaped.
  3. Claim up to FLEET_MAX_RUNNERS − inflight available Issues, lowest id first. Available = To Do, no fleet:* tag, every Predecessor-linked Issue Done. Claim = To Do → Doing + tag fleet:claimed + a stamped comment, plus a local claimed-at marker for the watchdog. The branch is derived deterministically: feat/slice-<id>-<kebab-of-title> (suffix -aN on retries).
  4. Launch — one runner-wrap.sh <issue-id> <branch> <attempt> per claimed slice, as the agent-service command of its own per-slice ephemeral Docker compose project via SandboxAccess (build + compose up -d, non-blocking; docker compose logs against the slice project is the live view). A failed launch rolls the claim back (tag removed, Doing → To Do, comment), so no slice is stranded.

Only claim/launch depends on credentials beyond the board reads: a working ADO PAT (claiming a slice does host-side git remote ops — worktree create off origin/main, then push — over HTTPS+PAT, no SSH key) and a working claude (the contained runner is the fleet's single LLM call — finalize and reap are deterministic). A tick with claim work pending runs two preflights first, in order, and short-circuits on the first failure:

  1. PAT preflight — git ls-remote against the target remote (the live checkout's origin, else FLEET_APP_REPO_URL) using the env-var PAT credential helper, non-interactively (GIT_TERMINAL_PROMPT=0) with a 30s timeout. This exercises the exact auth path every host-side git op uses, so it cannot pass while a real claim's git op fails. A rejected/expired/wrong-scope PAT, an unreachable remote, a timeout, or no git all read as a failure.
  2. claude preflight — a throwaway claude -p 'reply READY' --dangerously-skip-permissions --model "$FLEET_MODEL" probe with a 120s hard timeout, passing only on exit 0 plus READY in stdout (dead auth, a transient API outage, and an unavailable model read identically).

On either failed probe the tick degrades to the reap pass only — every claim/launch decision is dropped (no slice is claimed, no To Do → Doing, no fleet:claimed), while in-flight finalize and reap still proceed — and retries next tick. The PAT failure logs one actionable line naming the fleet-ado-pat Key Vault secret and the rotation runbook (the consuming repo's docs/contributing/afk-fleet.md → Key Vault secrets & PAT rotation). The PAT probe runs first, so a dead PAT never pays to spawn claude. Idle, saturated, and finalize-only ticks never pay for either probe.

Finalize retires slices that are truly done: Issue Done and a completed PR for the slice branch. For each, it runs the consuming repo's /cleanup-merged-branches skill headlessly for that branch, drops every fleet:* tag, comments the PR link, and sets the status phase to done. A failed cleanup is retried next tick.

Reap (watchdog) recovers from dead runners, with two independent guards before it acts: stale (best liveness evidence older than the staleness threshold) and dead (the runner.pid sidecar process no longer exists). A stale-but-alive runner is always left alone. A failed park (parked_state=failed with no parked tag) is positive failure evidence and skips the staleness wait — reaped immediately once the pid is confirmed dead. Deliberately parked runners are never reaped. Reaping archives the dead worktree to .claude/fleet/<issue-id>/archive/attempt-N/, records the reap, drops fleet:claimed, and moves Doing → To Do; the next claim retries with attempt+1, and exhausted retries escalate to fleet:failed instead.

Dry run — squadra tick --dry-run (or FLEET_DRY_RUN=1, or python -m squadra.supervisor --dry-run) runs the same three passes' read+plan logic and reports the would-be actions, with every side effect suppressed at the TickSeams boundary (dry_run_seams): board writes become logged [dry-run] WOULD … no-ops, no runner pane launches, neither preflight probe runs (no git ls-remote for the PAT, no claude spawn for auth), no worktree is archived, no local status/marker file is written. Safety is the wrapped boundary, not a flag inside the passes, so a future pass cannot forget to honor it. Only the tick lock and the supervisor log are still written — coordination artifacts, not fleet state.

Scoping: [board].parent_scope_ids (a list of parent work-item ids, optional) restricts claiming to slices under those parents; it supersedes the legacy FLEET_EPIC_IDS env (comma-separated Epic ids), which is still honored. Empty (the default) means every unblocked queued work item in the project is eligible.

Host-side git hardening (sandbox-escape control)

The slice agent runs in a sandbox with the slice worktree bind-mounted as /work, and it commits there — so a prompt-injected or misbehaving agent can plant a git hook (pre-push, post-checkout, …) or set core.hooksPath to an agent-controlled directory inside the worktree. Host-side git ops later run against that same worktree in the supervisor context, which holds the ADO PAT and the VM managed identity. A hook firing there would be a sandbox escape into the credential-holding host.

squadra closes this by routing every host-side git invocation through squadra.git_host, which:

  • pins -c core.hooksPath=/dev/null on the argv (a command-line -c outranks any config the agent set, so neither a planted hook file nor an agent-set core.hooksPath can execute); and
  • on a checkout op, adds -c safe.directory=<that exact path> to clear git's dubious-ownership refusal when the op runs as a different OS user than the FLEET_HOME/worktree owner — scoped to the path, never safe.directory=* (a wildcard would trust every repo on the host and, paired with a planted hook, reopen the escape).

Both are transient command-line overrides — never written to .git/config, so the agent cannot strip them. The worktree create/archive/prune, branch delete, the base..HEAD commit count, and the app-repo bootstrap all inherit this automatically by going through the builder. The host-side push of a slice's commits — the credential-holding op the agent does not perform — is not yet wired in code (the deferred write-tail in the supervisor's _handoff); when it is, it must be built as host_git_argv(*credential_helper, "push", "origin", branch, work_dir=worktree), and tests/test_git_host.py already proves that contract neutralizes a planted pre-push hook (with a bare-push control), so a push that bypasses the builder is a reviewable regression rather than a silent escape.

Manual operator pushes against a fleet worktree must carry the same guard — run e.g. git -c core.hooksPath=/dev/null -c safe.directory="$PWD" push … (or export GIT_CONFIG_PARAMETERS) rather than a bare git push, so an operator's hands-on op cannot trip a planted hook either.

Activation (manual, opt-in)

Nothing starts the fleet automatically. Scope claiming with [board].parent_scope_ids (or the legacy FLEET_EPIC_IDS) before enabling. Two levers compose:

  • A dry run first (the safest first step): squadra tick --dry-run (or FLEET_DRY_RUN=1) runs the full finalize/reap/claim read+plan logic and logs every action a real tick WOULD take, but cannot mutate — board writes, runner launches, the preflight probes (the PAT git ls-remote and the claude auth spawn), and local fleet-state writes are all suppressed at the seams. Ticks log to $FLEET_ROOT/supervisor.log, so review the plan with squadra log.
  • One tick by hand: squadra tick — logs to $FLEET_ROOT/supervisor.log. Note that FLEET_MAX_RUNNERS=0 is not a read-only tick: it only zeroes the claim budget; finalize and reap still mutate the board (drop fleet tags, comment PR links, run the headless cleanup, move Doing → To Do). For a tick that cannot mutate, use --dry-run.
  • The fleet-host (production): systemd. The dedicated fleet-host VM schedules ticks with systemd, not tmux (ADR-0002 §11): a squadra.timer fires a oneshot squadra.service running squadra fleet-tick, which fetches the PAT + ANTHROPIC_API_KEY from Key Vault via the VM managed identity, syncs the app repo, and runs one tick. Render + install the units with squadra install-units --key-vault <kv> --fleet-home <checkout> [...]; this does not enable the timer. Provisioning, the on-host acceptance (goss), and activation (systemctl enable --now squadra.timer) live in docs/fleet-host/.
  • Local / dev on demand: tmux. squadra {start|stop|status|log} drives a detached fleet-ticker tmux session whose loop fires one tick every FLEET_TICK_INTERVAL_SECONDS (default 180): start is idempotent, stop kills it (in-flight runners in the separate fleet session keep going), status reports + tails, log tails (-f to follow). This is for hands-on local runs; the fleet-host uses systemd, above. (The boot-time fleet-autostart.sh / cron autostart paths have been retired in favor of systemd.)

Each fire is a fresh supervisor process under the same lock (the timer is only the schedule, so crash-only semantics are preserved). In-flight slice state is reconstructed from the board plus the bind-mounted .claude/fleet/ status files, so a re-started ticker resumes cleanly.

Watch the fleet: squadra status (is-it-running + recent log), squadra log -f (follow the supervisor log live), the board (fleet:* tags) is the macro view, per-slice status.json is the micro view, docker compose logs against a slice's compose project is the live agent view.

Development

uv sync                        # env + deps
uv run ruff check .            # lint
uv run ruff format --check .   # format
uv run pyright                 # strict type check
uv run pytest                  # unit + hermetic shell tests

The shell glue (runner-wrap.sh, fleet-tick.sh, fleetctl.sh) ships as package data under src/squadra/_scripts/. Anything that needs to invoke it — the supervisor's SandboxAccess launch, the squadra dispatcher, the tick entry point — resolves it via squadra._resources.resolve_script(...) (importlib.resources + chmod +x), never a path relative to FLEET_HOME. The fleet-host systemd unit templates ship the same way under src/squadra/_units/ (resolved via resolve_unit(...), rendered by squadra.units).

Contributors

rinman24

Issues