Assumes: you've installed the
orchestratorClaude Code plugin and run/orchestrator:setupin your project — seeGETTING_STARTED.mdif you haven't. Agents, commands, hooks, and scripts ship with the plugin;/orchestrator:setupscaffolds the repo-specific residue the plugin can't carry (.claude/gates.json,CLAUDE.md, the fan-out workflow, the CI gate workflow). Everything below is what you do once that's in place — day-to-day driving, the human checkpoints, and the autonomous loop.
Two ways to drive it: conversational (the orchestrator subagent — flexible) or workflow (deterministic fan-out). Plus the human checkpoints that keep you in the loop.
Best for one-off or exploratory tasks where the shape isn't known up front.
Kick off:
Use the orchestrator agent. Task: <describe the goal>.
Scope it into non-overlapping sub-tasks by module, show me the plan, and WAIT for approval.
The orchestrator reads gates.json + CLAUDE.md, decomposes, and presents a plan in plan mode.
Approve / adjust the plan — this is your planning checkpoint. Check: are sub-tasks truly independent? Is each inside one module? Too many workers for the size? Then approve.
Let it run. Each sub-task goes to an implementer in its own worktree/branch; on "done" the orchestrator
fans the change to reviewers (one per lens); rejects loop back to the implementer until clean.
Mid-run controls:
- "Status?" → orchestrator emits the standup block (done/in-progress/blocked, branches, gates, risks).
- "Pause worker B / drop sub-task C / re-scope D."
- "Show me worker A's diff before it opens a PR."
Best for repeatable, known-shape work (a feature with clear parts, a migration, a sweep). Token-heavy, so it's gated behind explicit opt-in.
ultracode run the feature-fanout workflow with task: "<your task>"
or ask: "Run the feature-fanout workflow, args.task = '…'". It runs Scope → Implement → Review → loop
(up to 3 iterations/sub-task) and returns approved vs. needs-human results. Watch live with /workflows.
Tune .claude/workflows/feature-fanout.js: LENSES, MAX_ITERS, model per stage, worktree isolation.
A clean way to feed the orchestrator one task at a time is a module-labeled GitHub Issues backlog. The
template ships .claude/scripts/seed-issues.sh as a starting point: it derives module:<name> labels from the
modules in gates.json (plus type:feature/type:infra), and bulk-creates issues idempotently (re-running
reuses labels and skips titles that already exist). Replace the placeholder TICKETS section with your backlog,
then:
gh auth login # once
bash .claude/scripts/seed-issues.shDrive one issue at a time: "Use the orchestrator agent. Task: implement issue #N. Scope it within its module, show the plan, and WAIT for approval." Keep each ticket scoped to ONE module so workers get non-overlapping boundaries.
| Ceremony | Mechanism | What you do |
|---|---|---|
| Sprint planning | Plan mode (ExitPlanMode) |
Approve/adjust the decomposition before any code |
| Daily standup | /workflows board · "Status?" · TodoWrite list |
Glance at progress; unblock |
| Sprint demo | PR-per-agent + /review + verify/run skills |
Review each branch; see features actually work |
| Retro | npx ccusage + run notes |
Tune routing, worker count, prompts for next run |
implementer ──done──▶ gates (build/lint/types/test/coverage via gate.sh + hooks)
│ red → implementer keeps working (Stop hook blocks finish)
▼ green
reviewers (1 per lens, adversarial)
│ any reject → reasons fed back → implementer iterates
▼ consensus approve (per gates.json review.consensus)
PR / merge (per gates.json merge.policy)
With pr-per-agent, the standing loop per ticket looks like:
- Plan — tickets are the backlog (GitHub Issues work well — see the seeder example), planned with the orchestrator or added manually.
- Build — orchestrator scopes → implementers (isolated worktrees) → reviewer lenses → gates green.
- PR — created with
.claude/scripts/bot-gh.sh pr create …so the PR author is a bot machine account, not the repo owner. GitHub hard-blocks PR authors from approving their own PRs, so PRs created under the owner'sghauth can never receive a formal Approve. One-time setup lives at the top ofbot-gh.sh(free machine account → collaborator → classicrepo-scope PAT →GH_BOT_TOKENin.env). Reuse ONE generically-named bot across all your repos — GitHub ToS allows one free machine account per person. Onlypr createuses the bot; commits/pushes stay on the owner's auth. Per-repo grant (easy to miss): the bot must be a collaborator on every (private) repo it opens PRs in — adding it once to one repo does not cover the rest. Without it,ghfails with an opaqueCould not resolve to a Repository with the name '<owner>/<repo>'(looks like a typo, is actually a missing grant).bot-gh.shpreflights this and prints the fix; the one-time setup is, as the owner:gh api -X PUT repos/<owner>/<repo>/collaborators/<bot> -f permission=push, then accept as the bot:bot-gh.sh api -X PATCH user/repository_invitations/<id>(private-repo invites require acceptance). Owner notification:bot-gh.sh pr createauto-assigns the new PR to the repo owner (unless the caller already passed--assignee/-a) so the owner gets a GitHub notification that review is awaited. The owner login comes from$OWNER_LOGINif set, else — for cross-repo calls — from the--repo/-Rtarget's owner, else it's parsed from the localorigingit remote. If it can't be resolved, the PR is still created — just unassigned. - Review — the owner reviews on GitHub. To address comments, feed them back through the orchestrator ("address the comments on PR #N"): same implementer loop, same branch, push updates the PR in place.
- Merge — owner approves, merge per
gates.json.merge, clean the worktree (below).
Closing the loop automatically: webhooks rarely reach a dev box, so poll. Two firing sources exist — the
cron-less loop daemon (systemd --user, recommended — see below) or the
legacy session-scoped cron (/orchestrator:pr-loop, kept as a fallback for environments without
systemd). Both ultimately run the same three loop scripts in order — each a single stable command to
pre-approve in settings.json, since an inline compound command (loops, $(), redirects) never matches a
permission rule and would block on a prompt every firing:
bash .claude/scripts/notify-poll.sh— prints new issues and PR comments/reviews since a cursor file (.claude/state/notify-cursor, gitignored), plus a cursor-independentopen pr statussection (per open PR: latest owner review, CI rollup, mergeable) so the loop sees merge-readiness, which is a state, not an event. Summarize new items.bash .claude/scripts/pr-feedback.sh— lists open bot PRs with unaddressedCHANGES_REQUESTEDfeedback (deduped via a<!-- claude-addressed -->marker). For each, dispatch the orchestrator to address the comments on the same branch and push — the implementer posts the marker after pushing.bash .claude/scripts/merge-ready.sh— merges every open PR the owner has APPROVED that is mergeable and CI-green, then deletes the branch. The human Approve is the only merge gate; the script never approves. Safety: it merges only if the approval was submitted at/after the PR's last commit, so a free private repo (no branch protection to dismiss stale approvals) never auto-merges commits you haven't reviewed — pushing after approval requires re-approval. Uses ambientghauth (merging is an owner action; only PR creation uses the bot).
Or run it as one script: .claude/scripts/loop-tick.sh runs the census (loop-census.sh) plus all three
scripts above, IN ORDER, with their full output preserved, and prints exactly one machine-readable verdict line
at the end — action=none, action=advance issue=N, or action=feedback pr=N — collapsing the whole tick
into a single pre-approvable command. It also owns a self-healing spawn lock
(.claude/state/loop-advance.lock) so a second tick fired before the first ADVANCE has even reached PR stage
never double-spawns an orchestrator for the same issue: the lock is released once the issue's branch exists
(work has reached PR-race stage) or an open PR exists, OR — if neither ever happens because the spawn
crashed before pushing a branch — once the lock is older than its 15-minute TTL, so a crashed spawn cannot
wedge the issue forever. The read-check-write around the lock is additionally serialized with flock so two
overlapping ticks can't both pass the check and double-spawn; see the script's header comment for the full
contract.
With all three wired, the loop runs hands-off: add issues → review → approve → it merges and advances.
A natural step 4 is to start the next module:* issue only when no PRs are open, so work stays
serialized (one issue in flight) and bounded. How it actually fires — the recommended cron-less daemon
vs. the legacy session-scoped cron, adaptive cadence, the run ledger, and the one non-self-healing failure
state — is covered in Cron-less loop (daemon) below.
Running it fully hands-off? Polling still leaves a human approving each tool call. To let the loop
run unattended (Claude Code bypassPermissions), first harden the environment so the prompt is replaced
by always-enforced guardrails — see HARDENING.md (deny list + OS sandbox + host
isolation). Don't enable bypass without it — this applies equally to the daemon's headless driver spawns,
which have no interactive tty to prompt at all.
Recommended default (issue #102). Two systemd --user services replace the session-scoped cron:
pr-loop-<repo>.service→.claude/scripts/loop-daemon.sh,Restart=always. A genuine forever loop supervised bysystemd, not a Claude Code session — it survives Claude Code restarting or exiting entirely. Each iteration runs.claude/scripts/loop-event.sh, which runs the deterministic tick (loop-tick.sh) and parses its LAST-line verdict byte-identical (issue #81 contract: the verdict is computed once, in shell, never re-derived by a model). Onaction=nonethe daemon sleeps and loops — no model/driver process is ever touched, so a quiet repo costs nothing beyond the tick's ownghcalls. On an actionable verdict (action=advance issue=N/action=feedback pr=N) it spawns exactly one contained driver, as a transientsystemd --userunit, decoupled from the daemon's own lifetime (issue #119):systemd-run --user --wait --collect --unit="pr-loop-driver-issue<N>" -p RuntimeMaxSec=<LOOP_DRIVER_TIMEOUT, default 90m> -- bash -c 'claude --model <model> -p "<prompt>" --output-format json > <out-file> 2> <err-file>'(unit ispr-loop-driver-pr<N>for afeedbackverdict).--waitmakes the daemon's own invocation a disposable waiter: it blocks and relays the unit's exit code exactly like a backgroundedwaitwould, but the driver's REAL parent is the user manager, which lives in its own scope outside the daemon's cgroup — sosystemctl --user restart pr-loop-<repo>, a daemon crash (Restart=alwaysbounce), or the daemon dying for any other reason kills only that disposable waiter, never the driver itself.RuntimeMaxSec(a systemd time-span —90mworks as-is) replacestimeoutas the hard wall-clock ceiling, now enforced by the user manager instead of the daemon, so an orphaned driver still has a real ceiling even if the daemon never restarts. Restart is now driver-safe — a host reboot/sleep orwsl --shutdownstill kills the driver along with everything else (nothing can prevent that), but a plain daemon bounce no longer does.run_driver's ledger + issue #111's post-exit verification are unchanged; only the spawn+wait step branches. Fallback (legacy-cron / non-systemd environments, detected viacommand -v systemd-run): the exactsetsid timeout --kill-after=30s <LOOP_DRIVER_TIMEOUT> claude ...spawn from before #119, unchanged —setsidgives the driver (and any bash children it spawns) its own process group, independent of the daemon's; on timeout the whole group is targeted, not just the immediate child, so a driver's own children can never be orphaned by a bareSIGTERM. That fallback path still has the pre-#119 caveat: a process group is not a cgroup, so it dies WITH the daemon's own service cgroup on a restart/crash/reboot. Startup re-attach: before the first tick,main()checkssystemctl --user list-units 'pr-loop-driver-*'(no-op when systemd is unavailable) — if a driver unit from a previous, now-dead daemon process is still active, the daemon WAITS for it instead of ticking (no duplicate spawn, no premature debris classification), then runs the same post-exit verification + ledger write a fresh spawn would have.loop-census.sh's ADVANCE check additionally never picks an issue whose driver unit is currently active, even before that driver has reachedgit checkout -b(so it has no branch yet for thein_flightcheck to catch). Ops helper:.claude/scripts/loop-halt.shstops one driver (loop-halt.sh issue106/loop-halt.sh pr42), all drivers (loop-halt.sh --drivers), or the daemon + all drivers together (loop-halt.sh --all) — see the failure contract below for when you'd want each. The daemon itself never runs two drivers concurrently (it's a single-threaded loop), andloop-tick.sh's own spawn lock additionally guards against a second overlapping tick anywhere else (e.g. the legacy cron armed at the same time) double-firing the same ADVANCE.claude-rc-<repo>.service→claude remote-controlinside a detached tmux session (rc-<repo>), for spawning new planning sessions remotely — from claude.ai or the Claude Code mobile app — decoupled from the loop's own ticking.arm-loop.sh --capacity N --permission-mode <mode>controls its concurrency/permission posture.
Cadence. The daemon's sleep between ticks is read straight off loop-tick.sh's own census, whose
cadence=FAST|WATCH|IDLE cron=<expr> line already encodes the loop's desired attentiveness (FAST only when
there's something actionable now). loop-daemon.sh's cadence_to_sleep_seconds() maps that line to:
| Census cadence | Sleep |
|---|---|
FAST |
60s |
WATCH |
300s |
IDLE |
900s |
| (unparseable / missing) | 300s (fallback) |
Override any of the four via LOOP_DAEMON_SLEEP_FAST / _WATCH / _IDLE / _FALLBACK (seconds, test/debug
hooks).
Ledger. Every driver spawn — successful, timed out, refused-to-spawn, or re-attached at startup —
appends one line to .claude/state/loop-runs.log (gitignored, never committed):
pid=<pgid|unknown> session=<session_id|unknown> verdict=<advance issue=N|feedback pr=N> ts=<ISO8601> [result=exit|timeout|spawn-error rc=N] [pr=N] [debris=... [action=deleted]] [reattached=true]
session_id is parsed out of the driver's own --output-format json stdout, which is what makes a
hung or already-finished driver resumable later. A reattached=true line (issue #119) means the daemon
found this driver already running as a systemd unit at startup — left behind by a previous, now-dead
daemon process — waited for it instead of spawning a new one, and ledgered its real outcome; pid=unknown
on that line because the daemon that actually spawned it is gone.
Supervision. Three read-only windows into a driver, cheapest first:
tail -f .claude/state/loop-runs.log— the ledger line above, one per spawn.- The live transcript:
~/.claude/projects/<proj>/<session-id>.jsonl(<session-id>from the ledger line) — this file is append-only, sotail -fit (or read it directly) to watch a driver's tool calls as they happen without disturbing it. claude --resume <session_id> --fork-session— a full interactive replay/continuation. Safe to run while the driver is still executing:--fork-sessionnever mutates the original session, it only reads the append-only transcript and branches a new one.
Intervention is kill-and-let-it-re-advance, never steer. A driver is a headless claude -p process with
no attach point — there is no "type into it and redirect it" option. To stop one: bash .claude/scripts/loop-halt.sh issue<N> (or pr<N> for a feedback driver) — systemctl --user stop pr-loop-driver-issue<N> under the hood (issue #119). This is now the right tool on the systemd path: the
ledger's pid= is the daemon's own disposable systemd-run --wait waiter, not the driver, so killing that
pid does nothing to the actual driver. loop-halt.sh --drivers stops every active driver at once;
loop-halt.sh --all also stops the daemon unit. On the legacy fallback path (no systemd-run on PATH),
pid= is still a real process group id and kill -TERM -- -<pgid> (the same target timeout --kill-after would eventually use anyway) still works, same as before #119. Do not try to nudge a
running driver's behavior either way. Instead, let loop-tick.sh's own pre-branch spawn lock
(.claude/state/loop-advance.lock, 15-minute TTL) self-heal so a later tick can re-advance the same issue
cleanly, or fix forward with a normal orchestrator pass once whatever state the killed driver left behind (a
branch, a PR) is visible to a fresh tick.
Remote planning sessions. claude-rc-<repo>.service keeps a claude remote-control process alive in a
detached tmux session, independent of the loop daemon's own ticking, so you can spawn a new planning
session from claude.ai or the Claude Code mobile app against this checkout at any time — useful for filing
or scoping work from your phone without a terminal open. tmux attach -t rc-<repo> to see its QR/status
locally, or restart it with systemctl --user restart claude-rc-<repo>.service.
Linux vs WSL2. Both need systemd, tmux, and node/git on PATH. WSL2 does not run systemd by
default — add (or verify) a [boot] section with systemd=true in /etc/wsl.conf, then wsl --shutdown
from Windows (not WSL) and reopen the WSL terminal; re-check with systemctl --version inside WSL2.
Both platforms: installing units under ~/.config/systemd/user/, loginctl enable-linger, and starting the
detached tmux session all touch $HOME/systemd, which the Claude Code sandbox blocks — run
bash .claude/scripts/arm-loop.sh [--gates-file <path>] [--permission-mode <mode>] [--capacity N]in a real terminal outside Claude Code. It's idempotent (safe to re-run any time). Self-hosting: add
--gates-file .claude/self/gates.json. WSL2-only extra: optionally make the loop survive a Windows reboot
unattended — i.e. WSL2 boots at system startup, before anyone logs in, not just at logon. From an
elevated Windows PowerShell (Run as Administrator — required by -RunLevel Highest), substituting
<distro> from wsl -l and <user> for your Linux username:
$action = New-ScheduledTaskAction -Execute "wsl.exe" -Argument "-d <distro> -u <user> -- true"
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "Start WSL <distro>" -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings `
-Description "Boots WSL2 <distro> unattended at system startup so pr-loop/planner autostart"-LogonType S4U runs the task whether or not anyone is logged on, without storing a Windows password — it
needs the "Log on as a batch job" right, which admin accounts have by default (grant it via secpol.msc for
a non-admin account). WSL2 also idles its VM down after inactivity, which would stop the daemon even though
the task fired — add vmIdleTimeout=-1 under [wsl2] in %UserProfile%\.wslconfig, then run wsl --shutdown
once from Windows so it takes effect. Opening a WSL terminal afterward attaches to this same already-running
instance rather than starting a second one — WSL2 only ever runs one instance per distro, so there's no
duplicate-daemon risk.
Skipping this is safe: GitHub is the loop's only source of truth, so anything that happened while WSL2 was stopped is simply picked up by the first tick after the next manual WSL2 start.
WSL2 caveat — going down is safe for the QUEUE and for a plain daemon restart, not for a HOST-level
shutdown while a driver is mid-run. Issue #119 made a systemctl --user restart pr-loop-<repo> (or a
daemon crash) driver-safe: the transient driver unit lives under the --user manager, outside the daemon's
own cgroup, so bouncing the daemon no longer kills it (this is what actually killed five of six drivers on
2026-07-14/15, and is now fixed). What #119 can NOT fix: a Windows reboot, sleep/hibernate, or wsl --shutdown tears down the WHOLE WSL2 VM — user manager included — so a driver mid-run at that moment still
dies, now with no ledger line the same way a hard kill -9 on any process would. Check
systemctl --user list-units 'pr-loop-driver-*' (or tail -1 .claude/state/loop-runs.log for the last
spawn's completion) before a host-level reboot/shutdown; bash .claude/scripts/loop-halt.sh --drivers stops
any in-flight drivers cleanly first if you'd rather not wait.
Inspect what's armed:
systemctl --user status pr-loop-<repo>.service
journalctl --user -u pr-loop-<repo>.service -f
systemctl --user list-units 'pr-loop-driver-*' # active driver units (issue #119)
tail -f .claude/state/loop-runs.log
tmux attach -t rc-<repo>Stop things by hand (issue #119 — see .claude/scripts/loop-halt.sh):
bash .claude/scripts/loop-halt.sh issue106 # stop one driver: systemctl --user stop pr-loop-driver-issue106
bash .claude/scripts/loop-halt.sh pr42 # stop one driver: systemctl --user stop pr-loop-driver-pr42
bash .claude/scripts/loop-halt.sh --drivers # stop ALL driver units: systemctl --user stop 'pr-loop-driver-*'
bash .claude/scripts/loop-halt.sh --all # stop the daemon unit AND all driver unitsDegrades cleanly (logs and exits 0) when systemctl --user is unavailable — the legacy fallback path's
drivers are plain daemon children, already covered by stopping the daemon itself.
Failure contract.
| Failure | Detected as | Self-heals? | Manual fix |
|---|---|---|---|
| Driver exits non-zero (gate failure, crash mid-run, …) | ledger result=exit rc=N |
Yes — the next tick's fresh census decides the next action from scratch | none |
Driver runs past LOOP_DRIVER_TIMEOUT (default 90m) |
ledger result=timeout rc=124|137; RuntimeMaxSec (systemd path) or timeout --kill-after=30s plus an explicit process-group kill (fallback path) |
Yes — same as above | none, unless it left a half-finished branch behind — inspect and fix forward |
claude CLI not found on PATH (nor via the nvm fallback) |
ledger result=spawn-error rc=127, logged before any spawn attempt |
Partially — the pre-branch spawn lock's 15-minute TTL clears and lets a later tick retry, but every retry hits the same missing-PATH wall |
fix PATH/nvm in the daemon's environment (e.g. the systemd unit's Environment=), then systemctl --user restart pr-loop-<repo>.service |
loop-tick.sh / loop-event.sh itself exits non-zero (broken tick) |
daemon logs "not spawning a driver on a broken tick", sleeps the fallback cadence, retries | Yes — retried automatically every tick | investigate only if it persists across many ticks |
Driver created feat/issue-N-* but died before opening the PR |
census reports N as in_flight; loop-tick.sh's advance check refuses (# advance refused: issue=N is in_flight, logged every single tick) and emits action=none |
No — unlike the pre-branch spawn lock, in_flight has no TTL/self-heal; it refuses forever until the branch or a PR's state changes |
Classify first, never blind-delete (git log main..<branch> + worktree status): empty (no commits, clean) → delete the branch/worktree, freeing the issue back to advance_ready; publishable (commits ahead, clean, reviews/gates were green) → re-run the test gate, push, open the PR by hand via bot-gh.sh (proven: #107 → PR #117); half-done or dirty worktree → never delete — wip:-commit to preserve, then finish via a normal orchestrator pass on the existing branch. Issues #111 (classify + mechanical paths) and #98 (resume half-done work) automate this. |
| Daemon killed mid-driver (service restart, daemon crash) — fixed by issue #119 | ledger shows a reattached=true line once the daemon restarts and re-attaches, with the driver's real outcome (not a missing/phantom line) |
Yes — the transient driver unit outlives the daemon's own cgroup; main()'s startup re-attach waits for it and runs the same post-exit verify + ledger write a fresh spawn would have |
none — this is now self-healing. On the legacy fallback path (no systemd-run on PATH) the pre-#119 behavior still applies: no result= completion line, driver dies with the daemon's cgroup; recover debris via the row above |
Host-level kill while a driver is in flight (reboot, sleep/hibernate, wsl --shutdown) — the one case #119 can't fix, since it tears down the --user manager too |
ledger shows a spawn with no result= completion line; events.jsonl/worker-tools.jsonl activity for the task stops abruptly |
No — there is no process left anywhere to re-attach to; the restarted daemon just ticks on, and any branch the driver created wedges as in_flight per the row above |
check systemctl --user list-units 'pr-loop-driver-*' (or the ledger's last spawn) before a host-level reboot/shutdown, or loop-halt.sh --drivers first; recover debris per the in_flight row above |
claude-rc-<repo>.service's inner claude remote-control process crashes |
not detected by systemd — the unit is Type=oneshot/RemainAfterExit=yes; systemd only observes tmux new -d's own (successful) exit, never the health of the process running inside that tmux session |
No | tmux attach -t rc-<repo> to check, then systemctl --user restart claude-rc-<repo>.service |
Never run the daemon and the legacy /pr-loop cron against the same repo at the same time —
loop-tick.sh's spawn lock makes double-firing safe (no double-spawn), just wasteful (two firing sources
burning ticks against identical state).
Legacy cron (/pr-loop / /orchestrator:pr-loop). Kept as the fallback for environments without
systemd: session-scoped CronCreate, dies with the Claude Code session that armed it, so it must be
re-armed at the start of each session (/orchestrator:pr-loop does that plus runs one tick
immediately). Same adaptive cadence, same loop-tick.sh mechanics underneath — it just fires from inside a
Claude Code cron instead of loop-daemon.sh.
Each tick — whether fired by the daemon's loop-event.sh/loop-tick.sh or the legacy /orchestrator:pr-loop
cron — runs, in order: poll → merge → address-feedback → advance — this per-tick order, canonically
defined in .claude/commands/pr-loop.md and .claude/scripts/loop-tick.sh, is authoritative; the poll /
address-feedback / merge scripts described above are the mechanism it runs. Two human control points
decide what the loop actually touches:
-
Issue approval is a two-label workflow:
backlog→planned. An issue enters the loop's work queue only when it carries both theplannedlabel and amodule:<name>label:backlog— filed but not approved. This is the default state for every new issue, including issues the bot/agents file themselves (agents MUST label their own issuesbacklog, neverplanned). The loop never touches abacklogissue.planned— the owner's formal approval: "scoped, reviewed, do it." Only the repo owner assignsplanned— no agent, subagent, or bot may ever add this label, to an issue it filed or to anyone else's. Removingplanned(or closing) is the owner's way to pull work back out of the queue.module:<name>— routing, not approval. It maps issue → module → the worker'spathboundary (gates.json.modules[]). No module ⇒ no boundary ⇒ nothing safe to hand a worker,plannedor not.
The ADVANCE step picks the highest-priority, then lowest-numbered, open issue labelled
planned+module:*with no existingfeat/issue-<n>-*branch — one at a time, and only when there are zero open PRs. Tracking issues (plans split intoBlocked bysub-issue chains) staybacklogforever so the loop works the chain, never the tracker. "Blocked by #N" in an issue body is load-bearing for this selection, not merely cosmetic for the cockpit graph (loop-census.sh, issue #97): the census skips aplannedcandidate while any issue it declares "Blocked by" is still open, emits ablocked=<n> by=<N>line explaining the skip, and picks the next unblocked candidate instead — a blocker closing makes the skipped issue eligible again on the very next tick, no extra bookkeeping required. A "Blocked by" cycle falls back to the lowest-numbered candidate rather than wedging the loop. Only the explicit "Blocked by" phrase gates; task-list/parent-child refs (- [ ] #N) do not.- Priority labels (
priority:critical|high|medium|low, issue #173). The owner sets one of these four labels in the GitHub UI on anyplannedissue; nothing else about the workflow changes. The census orders candidates by (priority rank, then issue number):critical<high<medium<low< unlabeled, with issue number as the tiebreaker within a rank — so a label-free backlog behaves exactly as before (lowest-numbered first). The blocking-graph gate above still overrides priority: a blocked candidate is skipped regardless of how high its priority is — priority is preference, "Blocked by" edges are semantics. The cockpit shows a priority chip on each prioritized issue row.
-
Owner-approval merge gate. Workers author PRs as the bot (
bot-gh.sh); the MERGE step (above) only merges PRs the repo owner has Approved on GitHub that are CI-green and mergeable. It never approves on the owner's behalf.
Corollary: work is not loop-eligible until (a) its area exists as a module in
gates.json.modules[], (b) the issue carries the matching module:* label, and (c) the owner has
labelled it planned. Commenting "approved" on an issue does nothing — nothing watches issue text; the
planned label is the only approval signal.
Optional spec/plan gate (plan.gate, issue #100). By default the ADVANCE step goes straight from a
planned issue to implementation (one driver turn: scope → worktree implementer → gates → reviewers →
bot PR). gates.json's plan block can insert a reviewable plan artifact — a structured issue
comment — before any code is written:
plan.gate: "off"(default) — today's single-pass behavior, unchanged.plan.gate: "label"— gate only theplannedcandidates that ALSO carry aplan-firstlabel.plan.gate: "always"— gate everyplanned+module:*candidate, no extra label needed.
Label lifecycle for a gated issue:
- The owner adds
plan-first(label mode only;alwaysmode needs nothing extra). - The loop's next ADVANCE turn is PLAN-ONLY: it reads the issue, scopes it (module, expected files,
approach, acceptance-criteria mapping), and posts one structured comment on the issue beginning with
the marker
<!-- plan-gate:plan -->— implementing nothing, opening no branch, no PR. It then labels the issueplan-review(awaiting the owner) andneeds-human(issue #99's signal — see below). - The owner reviews the plan comment on GitHub. To approve: replace
plan-reviewwithplan-approved— the next tick implements the issue, with the plan comment's contents injected verbatim into the implementer's prompt and every reviewer's prompt as the authoritative scope (a diff that exceeds the plan's declared files/approach is a valid reviewer reject: "exceeds approved scope"). To request changes: removeplan-review— the issue drops back to needing a fresh plan, and a later tick re-plans it from scratch.
While an issue sits in plan-review (posted, not yet approved), loop-census.sh treats it like a Blocked by dependency: it is skipped for advance_ready entirely — the loop will not implement a plan the owner
hasn't signed off on.
Owner-only approval, same caveat as planned: the loop only checks whether the plan-approved label is
present, not who added it — GitHub identity is not enforced here any more than it is for planned itself
(see the two-label workflow above). Treat plan-approved as an owner-only convention, not a
technically-enforced gate.
This reuses issue #99's needs-human signal (label + one-time comment + throttled notification) as the
"plan is ready for your review" ping, rather than inventing a second escalation channel.
Model selection. Drive the loop's tick sessions with Sonnet. Ticks are cheap but highly
repetitive, and repetition is where smaller models degrade: a Haiku-driven tick session has been observed
to stop invoking the step scripts entirely — fabricating census/merge output from the pattern of earlier
quiet ticks (missing an owner approval and a planned issue for hours) — and to misread an in-flight
orchestration as hung, double-spawning orchestrators for the same issue. Reserve Fable or Opus for
the owner-side judgment work: scoping, planning, and filing issues. (Script-side hardening that reduces
the tick's model-dependence landed in issue #81 as .claude/scripts/loop-tick.sh: it computes the
census/feedback/advance verdict and the spawn lock in shell, rather than leaving that arithmetic to be
re-derived from a prompt every firing — Sonnet remains the recommended driver for the session that invokes
it, since the driving session still has to read the verdict and act on it, e.g. spawning the orchestrator.)
Historical note: before the
plannedlabel existed, themodule:*label alone was the opt-in queue. If a repo predates the split, treatmodule:*-only issues asbackloguntil the owner addsplanned.
Self-hosting this repo's own backlog? .claude/self/pr-loop-self.md runs the same loop mechanics
self-hosted, against this repo's own .claude/docs/examples/.github backlog, using
.claude/self/gates.json as the adapter (module map, gates, review lenses) instead of the placeholder
.claude/gates.json above. It lives under .claude/self/ (not .claude/commands/), so it is self-hosting-only
— not a registered slash command and never packaged to downstream installs of the plugin; ask Claude to read and
follow it directly. See .claude/self/README.md for the self-adapter contract.
New project? Wire this up with the new-project configuration checklist.
When a new version of the orchestrator plugin ships (new agents, commands, gate fixes, etc.), refresh the
marketplace listing and let Claude Code update the installed plugin:
/plugin marketplace update recode
Then re-stamp the files /orchestrator:setup scaffolded into your repo (gates.json, CLAUDE.md, the
fan-out workflow, the loop-daemon systemd unit templates + arm-loop.sh, the CI gate workflow) so they pick
up any changes shipped in the update:
/orchestrator:sync
This compares the version markers /orchestrator:setup already scaffolded against what the current plugin
ships and re-stamps anything behind — flagging local edits instead of clobbering them (see
.claude/skills/sync/SKILL.md) — so an update refreshes managed files (e.g. feature-fanout.js) without
re-running the whole interview, and never touches your own gates.json/CLAUDE.md/settings.json (those are
created once and left alone on every re-run). It also detects and warns about local leftovers from an old
vendored install (issue #134, see below) instead of silently deleting or restamping them.
The orchestrator plugin must stay enabled for everyday sessions, not just to run
/orchestrator:setup//orchestrator:sync — this repo does not vendor a local copy of agents/,
commands/, hooks/, scripts/, skills/ into your .claude/ (issue #134 reverted issue #128's vendoring
model). agents/commands/hooks/scripts/skills are read straight from the plugin cache
(${CLAUDE_PLUGIN_ROOT}) on every session; disabling the plugin between updates now breaks everyday sessions,
not just setup/sync. (Vendoring was reverted because nothing kept an unmanaged vendored copy updated outside
/orchestrator:sync, and resolve-roots.sh deliberately makes a repo-tracked .claude/scripts layout win
over ${CLAUDE_PLUGIN_ROOT} — correct for self-hosting/worktree gate runs, but it meant a stale vendored copy
permanently shadowed a fresh plugin install; see the reDeploy incident that prompted #134.)
If your repo was onboarded before #134 and still carries a local .claude/{agents,commands,hooks,scripts,skills}
copy (and possibly a .claude/.orchestrator-vendor marker), /orchestrator:sync flags it —
stale-vendor: ... safe to delete if it's byte-identical to the plugin's shipped copy (just shadowing the
plugin cache), or stale-vendor conflict: ... if it diverges (review before deleting — it may be a deliberate
local override). Sync never deletes it for you. Migration caveat: if you've already armed the loop,
deleting/refreshing files on disk is not enough — a running pr-loop/claude-rc systemd unit holds its OLD
script in memory until its unit restarts, so restart both after cleaning up:
systemctl --user restart pr-loop-<repo-slug>.service claude-rc-<repo-slug>.service (cf. the 2026-07-16
reCode deploy-lag incident, issue #131).
Maintainer note: bump
plugin.json'sversionon every real change./plugin marketplace updateonly re-fetches plugin content when the plugin's version string actually changes (.claude/.claude-plugin/plugin.jsonand, for the local-clone method,.claude/.claude-plugin/marketplace.json's matching entry). Merging a fix tomainwithout bumping that version means every existing installer's cached copy — at~/.claude/plugins/cache/recode/orchestrator/<version>/— silently never updates, even after/plugin marketplace update. Caught 2026-07-06: ahooks/hooks.jsonschema fix merged tomainbut didn't reach an already-installed consumer until the version string was bumped too.
The maintainer note above says to bump plugin.json's version "on every real change" — issue #176
formalizes WHEN and HOW that bump actually happens for a planned batch of work, replacing the ad-hoc
process from issue #136 (closed; treat this section as its successor).
The milestone IS the release unit. Each release milestone (e.g. "v0.3.0 — consumer rollout") contains
one special issue, titled Release vX.Y.Z (template: .github/ISSUE_TEMPLATE/release.md), whose body
declares "Blocked by #…, #…, #…" listing every OTHER open issue currently in that milestone. This
reuses issue #97's blocking-graph gate — already load-bearing for the loop's ADVANCE step (see
"Autonomous loop & the issue queue" above) — as the release gate itself: no new state machine. The
census will not advance the release issue until every sibling in the milestone has merged, so as long as
that "Blocked by" line is kept current (a one-line bot-gh.sh issue list --milestone ... --state open
query — see the template), the release issue self-schedules for whenever the milestone is actually done.
The cut. Once unblocked and labelled planned, the release issue is implemented like any other issue
— its implementer runs bash .claude/scripts/release.sh vX.Y.Z --issue <N>, which:
- bumps the
versionfield in both.claude/.claude-plugin/plugin.jsonand.claude/.claude-plugin/marketplace.json, - generates a dated, Keep-a-Changelog-style entry in
.claude/.claude-plugin/CHANGELOG.mdfrom merged PR titles since the last git tag (falling back to the full commit history when there is no prior tag — e.g. this repo's very first release), - tags
vX.Y.Z, commits the bumps + changelog, and pushes both, - "publishes" per the runbook above — the driver cannot run the interactive
/plugin marketplace updateslash command, and package-publish commands are policy-denied, so it prints that command as the manual owner follow-up instead of attempting it, - closes the milestone,
- auto-files a
Rollout & feedback: vX.Y.Zcompanion issue (below).
Supports --dry-run for testability: it performs the real file transforms (version bump + changelog
generation) so they can be inspected, but only PRINTS every git/gh side effect (tag, push, milestone
close, issue file) instead of executing it — see .claude/scripts/release.test.sh.
The rollout & feedback companion. The auto-filed Rollout & feedback: vX.Y.Z issue is deliberately
census-invisible: no milestone, and no planned label, until the owner triages it — the loop will
never pick it up on its own. It carries:
- a consumer-sync checklist for reDeploy and reDeFi via
/orchestrator:sync(sync v2's offline checks — deploy-lag, environment, labels, observability — issue #141), - test-focus areas derived from the released milestone's issue titles,
- a running section to accumulate feedback back-references as consumers report issues.
Label conventions. The companion issue is labelled feedback; incoming feedback that traces back to a
specific consumer is additionally tagged from:redeploy or from:redefi (both labels, plus feedback
itself, are created idempotently by release.sh at file time if they don't already exist). The version
being rolled out is always noted in the issue body. Owner triage — adding planned and, if warranted, a
milestone — is what turns a piece of feedback into loop-eligible work; until then it just sits in the
inbox.
Hotfix path. A bug found after release doesn't reopen the old milestone — file a vX.Y.Z+1
micro-milestone containing just that one bug issue, label it planned, let the loop fix it normally, then
cut the patch release through the exact same Release vX.Y.Z+1 → release.sh machinery above (its
"Blocked by" list will just be the one bug issue).
/orchestrator:feedback — the five-second consumer→inbox capture path (issue #177). The rollout &
feedback companion above assumes someone remembers to go file something; /orchestrator:feedback "<one-line description>" [--severity SEV] is the low-friction complement — run it from INSIDE a consumer repo
(reDeploy, reDeFi, ...) while the owner is actually using the plugin there, and it lands the same triage-inbox
issue in robercano/reCode without breaking flow. It asks for only two things — the description, and an
optional severity suggestion — and autofills everything else:
- origin label — derived from the consumer repo's
origingit remote (case-insensitiveredeploy→from:redeploy,redefi→from:redefi; neither match still files the issue labelled justfeedback, with the raw origin repo slug always noted in the body regardless), - installed plugin version — read from the plugin cache manifest
(
${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json, falling back to a local-clone install's.claude/.claude-plugin/plugin.json; degrades tounknownrather than crashing), - body template —
Observed/Expected/Severity suggestionsections plus an origin-repo + plugin-version trailer, - labels —
feedback+ whicheverfrom:*label matched. Same as the rollout companion issue, deliberately no milestone, noplannedlabel — it lands in the same census-invisible triage inbox above, for the owner to triage later.
Implemented as .claude/skills/feedback/SKILL.md + .claude/skills/feedback/feedback.sh — see the skill
for the full consumer-repo guard definition (no origin remote / origin is reCode itself / plugin not
installed here all degrade to a clear error and file nothing, never a partial issue) and the one deliberate
place in this whole plugin that calls plain gh instead of bot-gh.sh: the issue being filed genuinely
IS the owner's own feedback, so there is no bot-authorship-for-approval problem to route around.
docs/ROADMAP.md is GENERATED — never hand-edit it. The single source of truth is GitHub itself (open
milestones, issue labels/bodies, PR state); the file is a read-only rendering of that data, produced by
bash .claude/scripts/roadmap.sh --write. A manual edit to docs/ROADMAP.md survives only until the next
regeneration, at which point it's silently overwritten — the file itself carries a
GENERATED FILE — DO NOT EDIT BY HAND marker at the very top as a reminder. To change what the roadmap
shows, change the underlying GitHub state instead: relabel/reassign a milestone, adjust a
priority:critical|high|medium|low label, or edit an issue's "Blocked by #N" line — then re-run the
generator (or just wait for the next post-merge regen, see below).
What it renders, per open milestone (in natural version order — v1.0.0 < v1.2.0 < v1.10.0, not a plain string sort):
- every issue attached to that milestone, each with its priority chip (same label set as the census/cockpit
above), a derived state —
open/in_flight(afeat/fix/work/issue-<N>-* branch exists, no PR yet) /PR#N open/closed— mirroringloop-census.sh's own ADVANCE/in_flight logic so the roadmap never disagrees with what the loop itself would report, - a Mermaid graph of the "Blocked by" edges among that milestone's issues (the SAME parser
cockpit.sh --parse-blockinguses — one implementation, reused, not re-derived).
A trailing Feedback inbox section lists open feedback-labeled issues that carry no milestone yet —
exactly the census-invisible state described in "The rollout & feedback companion" above, before owner
triage assigns a milestone and turns it into loop-eligible work.
Regeneration. bash .claude/scripts/roadmap.sh --write regenerates it on demand (default, no --write,
prints the same Markdown to stdout instead — a preview that touches no file). merge-ready.sh also calls it
automatically after every successful merge, best-effort: a roadmap generator failure (missing gh
auth/network, a crash) is logged to stderr and never blocks, fails, or rolls back the merge that triggered
it. Every gh call the generator makes is REST-only and gh-2.4.0-safe, routed through bot-gh.sh like every
other script in this repo; the regenerated file's own commit/push (when the local checkout is cleanly on the
base branch) runs as plain git, the same as every other git operation in this repo.
pr-per-agent(default): each worker → branch → PR. You (or a merge step) integrate; conflicts surface at PR time. Cleanest/auditable.orchestrated-sequential-merge: a coordinator merges branches in dependency order, re-running gates after each. Faster when many finish together, but needs careful ordering.
After merging a branch, clean its worktree:
git worktree list
git worktree remove <path>Start at max_parallel_workers: 1–2. Raise it only when your review+merge throughput proves it can keep up.
If workers stall or collide, the fix is almost always a sharper module map in gates.json, not more agents.
Trivial or single-file changes: just do them directly. The 15× token multiplier isn't worth it. The orchestrator itself is told to use one worker and no parallelism for small tasks — hold it to that.
The owner reviews bot PRs by actually running the change. Doing that by hand is error-prone — the classic failure
is testing from the main working tree (which does NOT contain the unmerged PR), concluding "the fix doesn't work",
and bouncing the PR back. /orchestrator:test-pr <pr-number> removes that footgun:
- Resolves the PR's head branch and fetches the latest pushed commit.
- Creates a detached git worktree at
humanTest.worktreeDir/pr-<n>(default.worktrees/pr-<n>) — isolated from your main checkout and from any agent worktree on the same branch. - Runs
humanTest.prepareinside it (install + build) and printshumanTest.launchfor you to run.
Configure the commands once in .claude/gates.json:
"humanTest": {
"prepare": "pnpm install --prefer-offline && pnpm -r build",
"launch": "pnpm dev",
"launchPhone": "sh -c 'pnpm dev & devpid=$!; trap \"kill $devpid 2>/dev/null\" EXIT INT TERM; cloudflared tunnel --url http://localhost:5173 --http-host-header localhost'",
"worktreeDir": ".worktrees"
}Add humanTest.worktreeDir to .gitignore. Re-running /orchestrator:test-pr on the same PR fast-forwards the
worktree to the latest commit (idempotent). Tear down with git worktree remove <path>.
To iterate on a UI from a phone (touch gestures, small-screen layout) rather than a desktop browser, configure
humanTest.launchPhone and run /orchestrator:test-pr <n> --phone. It prepares the worktree exactly as above but
prints launchPhone instead of launch. launchPhone starts the dev server(s) and opens a public tunnel (e.g.
a cloudflared quick tunnel) to the running app, printing a https://<random>.trycloudflare.com URL you open in
the phone's own browser. The --http-host-header localhost flag makes the tunnel send Host: localhost, so a dev
server with a strict host allow-list (e.g. Vite) accepts it with no config change.
This is opt-in and attended, because the tunnel is public while up: anyone with the link reaches the app and
whatever backend/API it proxies, using the credentials in the worktree's .env. It's ephemeral — Ctrl-C tears down
the tunnel and the dev servers together. Don't leave it running unattended. Without --phone, behavior is
unchanged; if launchPhone isn't configured, --phone falls back to printing launch.