Skip to content

Repository files navigation

FlightDeck

Run N parallel Claude Code main-harness workers against a GitHub Project backlog -- each a full claude -p session (not a subagent), sandboxed in its own container (local Docker, or a cloud container via a pluggable provider) on a prepared git worktree -- coordinated by a deterministic Project-Manager loop that plans, assigns, reviews, fixes findings in-flight, and lands green commits to your integration branch behind a real test gate. Built to clear a Planned backlog autonomously without workers colliding or rushing.

Proven at scale (2026-06): drove a real Go + pnpm monorepo at 20 parallel workers with a pool of Sr.-Dev reviewers, landing dozens of items to local dev in a single run -- then handing off to human code review. The control lands locally and never pushes; promotion is the operator's.

Why main-harness workers (not subagents)

Subagents tend to rush and skip steps. Each worker here is a full claude -p session -- same rigor as an interactive one (TDD, full Definition of Done) -- scoped to exactly one backlog item. The worker commits and exits; its exit is the signal for the control to spin the next one.

A Task-tool subagent runs under its own condensed system prompt and is optimized to hand a final message back to the parent. It does not inherit your interactive session's full standing-rules enforcement (TDD, full DoD, the project ruleset) the same way a fresh top-level session does. So an under-specified subagent will still cut corners. That is why real build work runs on full claude -p workers, not subagents.

Vocabulary (General Aviation)

FlightDeck uses a light aviation theme around the dev roles (which keep their plain names: Operator, PM, Sr. Developers, Developers). The terms, defined plainly so they never cost a newcomer comprehension:

Term Means
depart / land launch a worker / merge its green commit to the integration branch
a flight one worker's run on one item
the manifest the backlog (the board's Planned set); "backlog" is a fine synonym
flight plan (flightplan.json) the planner's output: order + dependency DAG + conflict groups
checkride the land gate -- the review + test suite that certifies work before it lands
clearance the egress allowlist (the only network a sandboxed flight is cleared to reach)
debrief (debrief.json) the worker's completion record (status, commit, learnings, decisions)
grounded operator-facing word for a blocked item (the board Status option stays Blocked)
recall / scrub launch.sh recall (graceful RTB) / launch.sh scrub (hard stop)
the Center the live wave dashboard (bin/center.py, auto-opened by launch.sh on :8787)
AAR (after-action review) the optional post-wave full build + E2E against everything that landed

Pieces

File Role
bin/control.py the PM: owns the live queue, plans, assigns/monitors workers, review + fix-up + rebase-land, owns ALL board writes
bin/launch.sh convenience launcher: sources the worker token, rotating wave log, auto-pops the Center; recall / scrub
bin/center.py the Center: live event-driven wave dashboard (tails the wave log, serves an SSE page on :8787)
bin/providers.py execution-provider contract + local (Docker bind-mount) reference impl + RemoteProvider base
bin/worktree.sh create / prepare / reset-and-reuse / build-image / container-run / list / remove per-slot git worktrees
.docker/Dockerfile worker image = the prepare step (linux/arm64): harness + operator config baked, UID/GID-mapped agent user (G1/G2)
.lnav/wave.json lnav log format that color-codes the wave log for terminal tailing
prompts/plan.md, review.md, fixup.md, resolver.md launched Sr.-Developer prompts (plan, review gate, in-flight fix-up, merge-conflict resolver)

Model

control.py --repo <target> --workers N
   ├─ read Planned items + bodies (priority-sorted)   ← throttled LIVE re-reads (default;
   │                                                     --no-live-board = one cached read)
   ├─ ensure + PREPARE worktrees worker-1..N            (deps, env, hooks -- so gates actually run)
   └─ loop: free slot + next item?
            ├─ worktree reset -> fresh per-item branch off latest base
            ├─ board Status → In Progress
            └─ claude -p "<plan inline>" --dangerously-skip-permissions   (in worktree, bg)
        worker exits?  → debrief.json done? → mechanical gates (commit-shape, test-weakening)
                       → review agent → FIX-UP findings in-flight (bounded) → re-gate
                       → rebase-land + integration test (land gate) on a staging ref
                       → green: FF integration branch (board Committed)
                       → else: board Blocked + Gate code
  • Single PM, no lock (v2-A). One control hands out disjoint items, so there is no board contention by construction; the landing pipeline is serialized through this one process. (The v1 mkdir claim lock was removed once the single-PM model was committed.)
  • The review gate checks structure, not just lines. Beyond correctness, the reviewer runs a mandatory SEAM CHECK (anything the diff produces or consumes -- events, queues, tables -- must have its other end verified in real code; comments describing intended wiring are claims, not evidence), an acceptance-criteria walk with file:line evidence, and a narrow severity floor: findings whose concrete failure is payment/event loss, wrong money movement, an auth bypass, tenant-isolation breach, or entitlements not applied are blockers even when they look like edge cases -- with an explicit no-nit clause so style/coverage/docs findings stay warnings.
  • Fix-up is regression-guarded. If a fix-up round INCREASES the blocker count, the amendment is reverted wholesale, the loop stops, and the item goes Blocked for the PM to resolve directly -- a fix-up that makes things worse never iterates. One escape hatch (#18): a mechanical removed-test finding the fix-up explicitly declares a JUSTIFIED supersession (via superseded_tests in fixup.json, with replacement coverage) stops counting as a blocker -- the reviewer still adjudicates the diff -- so a legitimate test replacement can't loop the guard into reverting a better amendment.
  • Item codes are issue-number-suffixed and stable (#19). Every queue item's code (branch autopilot/<code>, log names, board writes) is the title's leading token PLUS the issue number (drafts: an item-id tail), so the code is identical in every board re-read -- sibling items that share a title prefix can never double-schedule, and a landed sibling can't change the survivor's identity. Pre-existing unsuffixed branches still resume.
  • Workers are timeout-aware and never lose work to the reaper (#21). The brief states the hard wall-clock budget with an 80% stop-building mark; a worker killed at the cap gets its worktree auto-snapshotted as a wip: commit on the item branch, and the block reason says timeout (distinct from review outcomes). Agent process artifacts (debrief/review/fixup json) are excluded from git at prepare time and stripped from commits at land time (#20), so they never pollute the integration history.
  • Workers never push and never touch the board. Branch-only commits; the control writes all status and owns the merge to the integration branch. Operator pushes + promotes past Committed.

Roles & where they run

Role Who / what Runs on Does
Operator you host Owns the board + promotion past dev (push → CI → prod). Final calls.
PM control.py (the deterministic core) host Read board → plan → assign → monitor → review → land to dev. On the host so a worker/agent crash never kills the controller.
Sr. Developers Planner, Reviewer, Fix-up, Resolver (claude -p) containers under --docker (host without) Senior calls, short-lived. Planner/Reviewer read (order/deps/gating; the land-gate review). Fix-up/Resolver write (amend the commit / resolve a conflict).
Developers Workers, one per item (claude -p) per-slot container Build end-to-end (TDD + full DoD), hand the PM a branch + debrief.json, exit. Containerized for isolation + baked toolchain + egress allowlist.

Under --docker, every launched claude -p is sandboxed: workers in per-slot containers, the planner inside the worker image (sandbox_planner, default on -- no host MCP roster), and the reviewer / fix-up / resolver as per-slot sidecar containers that share the worker's node_modules volume. Only the deterministic PM stays on the host, by design (a worker/agent crash never kills the controller), plus two short-lived host-side exceptions: the post-wave critic and the AAR fix-forward repair flight. The checkride (land gate) runs the hermetic container path when worker_db + egress are configured, else on the host in the staged worktree; the AAR verify uses the same hermetic container path. Without --docker, everything runs on the host.

Setup

Run the preflight + scaffold:

./setup.sh                 # check prereqs (python3, git, docker+daemon, gh+project scope), scaffold board_cache.json
./setup.sh --build-image   # also build the linux/arm64 worker image (bakes YOUR ~/.claude config; stays local)

It validates the host, copies board_cache.example.json to board_cache.json if absent, and prints next steps. gh board writes need the project scope (gh auth refresh -s project); headless container workers need a CLAUDE_CODE_OAUTH_TOKEN (claude setup-token, or run bin/get-token.sh to generate it and save it to ~/.claude/autopilot-token.env).

For the full step by step walkthrough (board cache ids, image build, token, egress, running, scaling, and troubleshooting), see setup.md.

Public-tool note: the repo ships the Dockerfile + build-image so each user bakes their own ~/.claude config locally. Never publish a prebuilt worker image -- it would contain personal config.

For best results, open your interactive Claude Code session in THIS repo. FlightDeck ships two Claude Code skills that only auto-load when your session's working directory is the FlightDeck repo: the public skill_control-guide (running & extending the tool) and a gitignored private skill for target-specific war-stories. cd into this repo (or point the Claude UI at it) and drive target repos with --repo <path> -- a session opened in the target repo won't load them. (Note bin/worktree.sh build-image is the exception: run it from INSIDE the target repo so it bakes that repo's Dockerfile.)

Docker resources & the worker image

Under --docker each worker AND each Sr.-Dev sidecar (review / fix-up / resolver) runs in a container, so size the Docker Desktop VM (Settings -> Resources) to your worker count: budget roughly ~2 GB RAM per worker (workers peak ~2-3 GB during a build/test, idle far lower) plus headroom for the Postgres + egress-proxy containers. CPU is rarely the limit -- workers are LLM-latency-bound (idle ~1% CPU). The reference setup ran 20 workers (slots) on an 18-core / 48 GB arm64 VM (Apple M-series, 64 GB host) comfortably (~8-12 GB actually used). Per-container caps (board_cache.json, applied under --docker) in that run: worker_memory: "4g", worker_cpus: "4", worker_pids_limit: "4096" -- the 4 GB cap sits above the ~2.5 GB build/test peak, and --cpus/--memory are per-container ceilings (overcommit is fine since actual use is low). A slot is EITHER building OR running a Sr.-Dev gate at a time, so total live containers stay <= the slot count. When scaling up, the ceilings bite in this order:

  1. Postgres max_connections -- the default 100 is exhausted around a dozen workers running parallel go test; raise it (ALTER SYSTEM SET max_connections = 300; + restart the container) and optionally cap go test -p in checkride_command. This is the real scaling wall, not RAM.
  2. Claude API concurrency -- watch the log for 429/rate-limit as you add workers.
  3. VM memory -- only at much higher worker counts.

The worker image (.docker/Dockerfile; bin/worktree.sh build-image [tag]) is ~2.5 GB, built linux/arm64: node:22-bookworm + Go 1.25 + pnpm 10.33 + golangci-lint + the @anthropic-ai/claude-code CLI, with your global ~/.claude config (CLAUDE.md + skills; MCP roster, credentials, and transcripts stripped) baked at the agent config path. It bakes no target-repo secrets. Per-slot node_modules lives in a named volume (autopilot-<slot>-nm) shared by the worker and its Sr.-Dev sidecars, so review / fix-up can run tsc/vitest against the worker's installed deps (an anonymous volume would give each sidecar an empty node_modules and jam the lander on frontend items).

Usage

bin/control.py --repo ~/code/your-repo --workers 3
bin/control.py --repo ~/code/your-repo --workers 2 --max-items 4 --base-branch dev --dry-run
bin/control.py --repo ~/code/your-repo --workers 3 --serve   # stay up, wave live additions
bin/control.py --repo ~/code/your-repo --workers 4 --base-branch dev --docker --model opus  # sandboxed, all roles on Opus

Convenience launcher. bin/launch.sh <same flags> wraps control.py: it sources the worker token (~/.claude/autopilot-token.env), passes every flag straight through, and writes to a rotating log -- the active run is always <repo>/worktrees/.wave-logs/wave.log (older runs roll to wave.1.log .. wave.5.log, beyond that dropped; AUTOPILOT_LOG_KEEP to tune). So lnav <repo>/worktrees/.wave-logs/wave.log always follows the live wave. --workers is optional: it defaults to AUTOPILOT_WORKERS (a machine-wide env default), else board_cache workers, else 5.

Wave lifetime = launching process lifetime. The PM is an ordinary child process: launched from an interactive session (including a Claude session's background shell) it DIES when that session closes, while the worker containers keep running orphaned until each flight finishes. For a wave that must outlive your terminal, launch inside tmux (or nohup setsid ...) and tail the wave log from anywhere. If a PM does die mid-wave: finished flights are salvageable (branches + debriefs survive); prune worktrees, review, and land by hand.

Recall or scrub a running wave. bin/launch.sh recall --repo <repo> (RTB) winds it DOWN gracefully -- stop assigning new work, let in-flight flights finish and land, leave un-started items Planned, then exit. bin/launch.sh scrub --repo <repo> HARD-stops -- terminate flights + their containers now and return their items to Planned. Equivalent signals to the control pid: SIGTERM = recall, SIGQUIT = scrub, Ctrl-C = recall (then scrub on a second press). Both clean up containers on every exit path (no orphans).

Scaling knobs (board_cache).

  • plan_batch_size (default 5) -- RAMP planning: instead of planning the whole backlog up front (minutes on a big board), plan the first N items, start the non-conflicting ones, then stream the rest N at a time PACED to fleet starvation. Time-to-first-worker drops from minutes to seconds; each batch is planned against only the currently-running set, so the cost stays small regardless of backlog size.
  • gate_workers (default 1) -- a POOL of Sr.-Dev gate threads that run review + fix-up CONCURRENTLY across finished items; only the rebase + integration suite + FF is serialized (a shared land-lock). 1 = the legacy single serial lander. Raise toward --workers to uncap throughput -- the serial integration suite (~one land at a time) is then the floor. The slot is held during its item's gate so its worktree isn't reset under the reviewer.
  • queue_filters (optional) -- scope a wave to a board subset by single-select field values (e.g. only items whose Track is in a given set), applied in the same paginated read. TRAP: a leftover filter from a previous wave silently EMPTIES the queue for new items that lack the field ("0 pickable items", clean exit, no error) -- check the filter before every launch and set the field on new stories. Useful corollary: an epic left WITHOUT the field value is invisible to the scheduler with no special-casing.
  • sandbox_planner (default on under --docker) -- run the planner inside the worker image (no host MCP roster -> no server-startup/tool-schema bloat) instead of a host claude -p.
  • shared_dep_cache (default off) -- persist the Go module/build caches + pnpm store across workers via named volumes; needs a worker image rebuilt with the cache dirs (see board_cache.example.json).

One log shows everything. The wave log is a consolidated live feed: each worker AND Sr.-Dev agent streams a condensed one-liner per tool-use/message ([role:item] -> Edit path / a text snippet), each gate finding is logged with its severity/origin/file:line/issue (not just a count), and the land gate logs progress (rebase -> integration suite -> result) with the suite output captured to <logdir>/land-<code>.log so a test-fail is inspectable.

The Center: watch the wave live. launch.sh auto-opens the Center at http://localhost:8787 (NO_CENTER=1 to disable, CENTER_PORT to move it) -- an event-driven, ATC-style dashboard fed by the wave log over SSE: per-item status and duration, gate findings, block reasons on the row, land results, and the AAR running/verdict state. lnav <repo>/worktrees/.wave-logs/wave.log remains the terminal-native alternative (the repo ships an lnav format that colorizes it).

Since the Phase 6 cut-over (2026-06-23) live-board is the DEFAULT: the PM assigns from the board's current Planned set (picking up items planned mid-run), reassigns a freed slot immediately while the finished item lands off-loop, and re-reads the Planned set on a throttle. Pass --no-live-board for the legacy static start-of-run snapshot + synchronous land. --serve keeps the PM polling past empty until you stop it; worker_ceiling (board_cache) enables credit-aware scale-up.

Fix-up, not bounce (default ON). When the review/gates find a finding (warning, error, or bug), the PM launches a fix-up agent that fixes it in-flight and amends the single commit, re-gates (bounded by fixup_rounds, default 3), and lands -- blocking only when a finding genuinely needs an operator. A round that increases findings reverts to the pre-fix-up commit and stops (regression guard). Pass --no-fixup for the legacy block-on-finding behavior. On a block, the PM also writes the board's Gate single-select (category classified from the reason via gate_map). A cost guard (attempt_cap, default 2) refuses to auto-re-wave an item that has already blocked that many times with the same signature -- a repeat loser is the PM's to resolve directly, not to re-feed to the gate that just rejected it. And a --no-land run that passes the gates leaves the item In Progress (green branch, not landed); the next launch re-picks In Progress items ahead of Planned to land them -- only a real fast-forward to the integration branch flips Committed.

Model selection. --model sets the model for every launched role (worker, planner, reviewer, fix-up, resolver) -- e.g. --model opus runs them all on Opus. For per-role control, set a models map in board_cache.json (e.g. {"worker":"opus","reviewer":"opus","planner":"sonnet"}); each role falls back to --model. The reviewer is a full agent in the worktree: it reads the changed code and its dependencies, runs the build, and shape-checks the diff (rejecting a commit that staged node_modules/.pnpm-store) -- so commit sanity lives in the Sr. Devs, not in deterministic PM gates.

dev must be free. The land step advances the integration branch with git branch -f, which git refuses if that branch is checked out in another worktree -- so preflight refuses to start when the integration branch is checked out anywhere. Park side worktrees on their own branch, not on dev.

One wave per board. The control takes a per-board wave lock (<repo>/worktrees/.wave.lock) and refuses to start a second control.py on the same board; a crashed wave's lock is reclaimed automatically (dead pid) on the next run (#44). It does NOT police an interactive /autopilot on the same board -- that is operator practice: do not run /autopilot during an control wave, and isolate any sensitive concurrent interactive work in its own git worktree so it can't contaminate a worker's base.

The target repo needs:

  • a board_cache.json (default at <repo>/board_cache.json) with project_id + the Status field id and its Planned/In Progress/Committed/Blocked option ids (optionally a Commit text field id -- the PM records the landed SHA there on a successful land) -- scaffold it from board_cache.example.json (or via ./setup.sh); optionally set gh_account to a project-scoped gh login so the PM pins it before board reads/writes (guards a silent active-account revert mid-run, #41); optionally gate_map (block-category -> board Gate option name), worker_memory/worker_cpus/worker_pids_limit (per-container caps under --docker), and checkride_command (the land gate -- runs in the staged worktree before the FF to the integration branch: the hermetic CONTAINER path when worker_db + egress are configured, else on the host; e.g. migrate an isolated test DB + go test ./.... Empty == no land gate, which blocks every land, so set it -- and reset that test DB at the start of the command (dropdb/createdb) so a stale shared DB can't false-pass or false-fail); optionally worker_db (a per-slot test DB on a running postgres container so the containerized DEVELOPER runs the FULL suite incl. real-DB tests and self-verifies before handoff -- the host land gate becomes a confirmation; off by default); optionally a top-level tracker block to route ALL board reads/writes to Jira Cloud instead of GitHub Projects ({"kind": "jira", "site": "<site>.atlassian.net", "cloud_id": "<uuid>", "project": "<KEY>"} -- absent means GitHub, byte-for-byte the legacy behavior; auth is acli's OAuth token read from the macOS keychain, statuses map 1:1 by name, priority maps Highest..Low <-> P0..P3, logical Blocked rides as blocked/gate-<category> labels + a reason comment since Jira has no Blocked status, and the landed SHA is recorded as a Delivered by commit <sha> comment; see board_cache.example.json _tracker for the full key list);
  • /worktrees/* gitignored (worktrees are created there at runtime);
  • optionally .autopilot-prepare.sh at its root to customize worktree prep (else auto-detect: copy gitignored .env*, pnpm install / npm ci, go mod download, install git hooks).

Status -- where it is now

The full pipeline runs end-to-end and lands real work to the integration branch. As of 2026-06-25 it has driven a large backlog across a real Go + pnpm monorepo (target repo + its GitHub Project board) at 20 parallel workers with a pool of Sr.-Dev reviewers, landing dozens of items to local dev in a single run behind a real test gate -- fixing review/test findings in-flight instead of bouncing them. From there the flow is human code review -> human merge of dev to origin/dev -> AI-assisted QA: the control lands locally and never pushes; promotion past the local integration branch is the operator's.

What works today:

  • Live-board PM loop -- assigns from the board's current Planned set (picks up items added mid-run), reassigns a freed slot immediately while the finished item lands off-loop.
  • Containerized workers (--docker) -- per-slot bind-mount, injected OAuth token, egress allowlist (squid proxy on an --internal net), per-container CPU/mem/pids caps, overload watchdog, secret scrubbing, baked operator config (global CLAUDE.md + skills).
  • Plan -> review -> fix-up -> land. Planner emits order/deps/conflict-groups; Reviewer gates the diff; the fix-up loop fixes findings in-flight (amends the single commit, bounded, with a regression guard); the land gate runs the integration test on a staging ref and fast-forwards the integration branch only on green. Every blocked item gets a Gate code.
  • Parallel Sr.-Dev gate pool (gate_workers) -- review + fix-up run CONCURRENTLY across finished items (a pool of Sr. Developers); only the rebase + integration suite + FF is serialized.
  • Ramp planning (plan_batch_size) + sandboxed planner -- plan a small head, start the fleet in seconds, stream the rest paced to consumption instead of waiting on one big up-front plan.
  • Graceful recall / hard scrub -- launch.sh recall (finish + land in-flight, then exit) / launch.sh scrub (terminate now, items back to Planned); signal-driven, never orphans a container.
  • After-Action Review (aar_command) -- when the wave returns, optionally run a full build + E2E against the INTEGRATED branch (everything that landed, together), catching cross-flight breakage the per-flight checkride can't. Runs in a container, never on the host. Red AAR = "do NOT push"; it never auto-reverts (you promote).
  • As-built pass (asbuilt_doc) -- once the wave returns, reconcile the target's system-of-record document with what actually landed. Bidirectional: it documents what shipped but is missing (typically a subsystem with no UI entry, which no count-based checker can see) and corrects what the document claims that the wave falsified (especially a control described as live that is built but NOT enabled). The window is anchored to the document's own last change, so a doc left alone for several waves gets the whole span on the next pass. It edits only that file, commits it to the integration branch, and never pushes. Guards reject the edit outright if it touches anything else, deletes a large share of the document, or fails the target's own doc gate. Standalone: control.py --asbuilt.
  • The Center -- launch.sh auto-opens the live wave dashboard (SSE on :8787): per-item status + duration, gate findings, block reasons, land results, AAR state.
  • Parallel-project tail numbers (project_code) -- each flight's Docker objects + log IDs become a tail N<nn><code>, so two waves on different repos never collide; a worker's E2E can reach the app by its real domain (test_app -- Docker DNS alias on the airspace + NO_PROXY bypass).
  • Resilient long runs -- transient gh/network retry (rides a ~90 s GitHub outage), gh-account re-pin per board op, per-board wave lock, crash-recovery state, auto re-rebase + retry when a land loses the ff race, idempotent slot-container restart, cost guard against re-waving repeat losers.
  • Pluggable execution providers -- local (Docker bind-mount) is the default + reference impl; a RemoteProvider base (clone-in / artifact-out) exists for non-co-located execution.

Reference host: Apple M5 Max (18 cores, 64 GB RAM), macOS, Docker Desktop VM allocated 18 CPUs / 48 GB, linux/arm64 worker image. Drove 20 workers + a 20-thread Sr.-Dev gate pool comfortably.

Built with TDD: python3 -m unittest discover -s tests (the canonical, zero-dependency runner; 460 tests, 5 Docker-gated behind AUTOPILOT_DOCKER_TESTS=1). pytest tests/ also works if you prefer it (the suite is plain unittest, so no project dependency on pytest). Decision log: CLAUDE.md. Phased history + requirements: docs/PRD.md.

What's next

  • Decouple builders from Sr.-Dev reviewers (dedicated review worktrees) so a worker can build the next item while review/fix-up runs on the previous one -- today the gate pool holds the slot during the gate (race-free first cut). The serial integration suite caps the payoff, so it's an efficiency win, not a throughput jump.
  • Crash-recovery hardening: reset In-Progress orphans -> Planned at startup after a crashed wave (a clean scrub already resets its items; a killed -9 PM leaves them stranded).
  • Phase 5 -- AWS/Fargate provider (P5-3): run workers in the cloud behind the same contract, gated on live AWS infra. (local stays the default; cloud runs the SAME image + headless claude -p.)

About

Run N parallel Claude Code workers against a GitHub Project backlog — sandboxed containers, plan→review→fix-up→land behind a real test gate. Lands to dev; humans promote.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages