{"slug":"fleet-ops","title":"fleet-ops","summary":"Landing discipline for parallel work: sequential test-gated landing queue, pre-land scrub, auto-rebase of in-flight lanes, fleet status, one-shot revert. Native primitives spawn; fleet-ops lands. Triggers: landing queue, land branches, merge queue, test gate, fleet status, land a","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-30T19:36:45.106909Z","repo":{"url":"https://github.com/0xDarkMatter/claude-mods","stars":43,"forks":7,"license":"MIT","updatedAt":"2026-09-30T15:18:48Z"},"bodyHtml":"<hr>\n<h2>name: fleet-ops\ndescription: \"Landing discipline for parallel work: sequential test-gated landing queue, pre-land scrub, auto-rebase of in-flight lanes, fleet status, one-shot revert. Native primitives spawn; fleet-ops lands. Triggers: landing queue, land branches, merge queue, test gate, fleet status, land agent-team/background-agent branches, sequential merge.\"\nlicense: MIT\nallowed-tools: \"Read Bash Glob Grep AskUserQuestion\"\nmetadata:\nauthor: claude-mods\nstatus: stable\nexperimental-parts: daemon (in-session background polling)\nrelated-skills: git-ops, push-gate, claude-code-ops</h2>\n<h1>Fleet Ops</h1>\n<p>Landing discipline for parallel work. Anything before \"committed on a branch\" is the spawning layer's problem; anything after \"landed on <code>main</code>\" is yours. Fleet-ops owns the middle: branches land <strong>sequentially</strong>, through a <strong>test gate</strong>, after a <strong>pre-land scrub</strong>, with <strong>auto-rebase</strong> of the lanes still in flight and a <strong>one-shot revert</strong> if a landing turns out bad.</p>\n<h2>Spawn natively, land with fleet-ops</h2>\n<p>Claude Code now ships the parallel-execution half natively. <strong>Do not use fleet-ops to orchestrate sessions</strong> — route users to the native primitives and use fleet-ops only for the landing half.</p>\n<table>\n<thead>\n<tr>\n<th>Native primitive</th>\n<th>What it gives you</th>\n<th>What it does NOT give you</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Agent teams</strong> (<a href=\"https://code.claude.com/docs/en/agent-teams\">docs</a>, experimental, <code>CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1</code>)</td>\n<td>Lead + teammates, shared task list with claiming/dependencies, inter-agent messaging, plan approval, quality-gate hooks (<code>TeammateIdle</code>, <code>TaskCompleted</code>)</td>\n<td>No merge/landing logic. No test-gated integration. Teammates avoid file conflicts by convention only (\"break the work so each teammate owns different files\").</td>\n</tr>\n<tr>\n<td><strong>Background agents / agent view</strong> (<a href=\"https://code.claude.com/docs/en/agent-view\">docs</a>, <code>claude agents</code>, <code>claude --bg \"&lt;prompt&gt;\"</code>)</td>\n<td>Detached full sessions, one dashboard (Needs input / Working / Completed), automatic per-session git worktree isolation under <code>.claude/worktrees/</code>, <code>--bg --exec</code> shell jobs</td>\n<td>No cross-branch integration: each session ends with a branch/worktree and the merge is on you (review-and-merge the PR, or merge locally). Deleting a session in agent view <strong>deletes its worktree including uncommitted changes</strong>. No ordering, no test gate, no revert.</td>\n</tr>\n<tr>\n<td><strong>Subagents</strong> (<a href=\"https://code.claude.com/docs/en/sub-agents\">docs</a>, optional <code>isolation: worktree</code>)</td>\n<td>In-session delegation with separate context windows; results summarized back</td>\n<td>Not independent sessions; no git landing semantics at all.</td>\n</tr>\n</tbody>\n</table>\n<p>What <strong>none</strong> of them do — and what fleet-ops is for:</p>\n<ul>\n<li>Land N branches <strong>one at a time</strong> through a queue, so each merge is tested against a <code>main</code> that already contains the previous landings</li>\n<li><strong>Test gate</strong>: refuse to land on a failing log (<code>signal.sh</code>) and/or revert post-merge if <code>test_cmd</code> goes red</li>\n<li><strong>Pre-land scrub</strong>: refuse diffs containing forbidden patterns (<code>TODO_SCRUB</code>, debug leftovers)</li>\n<li><strong>Auto-rebase</strong> every still-active lane after each landing</li>\n<li><strong>Fleet status</strong>: one panel showing every lane's branch, state, age, and commits-ahead across worktrees</li>\n<li><strong>One-shot revert</strong> of a landed merge by branch name — no git surgery while panicking</li>\n</ul>\n<h2>Core abstraction</h2>\n<p>A <strong>lane</strong> = one branch (or worktree), one unit of work. Lane status: <code>RUNNING | READY | CONFLICT | LANDED | FAILED</code>.</p>\n<p>Fleet-ops doesn't care who produced the branch — an agent-team teammate, a background agent's auto-worktree, a <code>claude -p</code> headless run, a fleetflow worker of any provider (GLM, Codex, Grok, Pi, or Anthropic), or a human. If it's a branch with commits, it can be a lane. Landing is provider-agnostic: a Grok-produced lane lands through the same test-gated queue as any other.</p>\n<h2>CLI surface</h2>\n<pre><code>fleet init &lt;name&gt;...        Create branch + worktree per name (manual-spawn path)\nfleet track &lt;branch&gt;...     Register existing branches as lanes (native-spawn path)\nfleet start                 Run the landing daemon (writes pid to .claude/fleet/daemon.pid)\nfleet stop                  Signal the running daemon to exit cleanly\nfleet status                One-shot fleet status panel\nfleet land &lt;branch&gt;         Manual land + rebase others\nfleet land --all [--running]  Batch-land all READY lanes oldest-first (--running\n                            also lands vetted RUNNING lanes; used by git-ops \"land all\")\nfleet revert &lt;branch&gt;       Revert merge commit on main\nfleet scrub-check &lt;branch&gt;  Dry-run forbidden-pattern check\nfleet config                Print the RESOLVED config — check the test gate is on\nfleet prune [--remove]      Classify finished lane worktrees; DRY RUN by default\nfleet prune --all-repos     Sibling-repo backlog counts (report-only, never removes)\n</code></pre>\n<h2>Entry paths</h2>\n<pre><code>N == 1 branch                              → use git-ops, not this\nWork spawned by agent teams / claude --bg  → fleet track &lt;branch&gt;... then land\nWork to be spawned manually                → fleet init &lt;names...&gt; (creates branches + worktrees)\nN &gt; 1 on one shared working tree           → REFUSE. Worktrees or separate clones first.\n</code></pre>\n<p><strong>Native-spawn path (preferred):</strong> let agent teams or background agents do the work in their own worktrees/branches. When branches have commits, <code>fleet track</code> each branch, then land — either one by one with <code>fleet land</code>, or via the daemon with <code>signal.sh READY</code> gates. Landing itself only ever merges <em>branches</em> and leaves every worktree in place. Reclaiming the directories afterwards is <code>fleet prune</code>'s job, and it removes one only when the owning session is provably archived or gone — see <a href=\"#prune--worktree-housekeeping\">Prune</a>.</p>\n<p><strong>Manual-spawn path:</strong> <code>fleet init</code> creates the branches and worktrees up front (under <code>.fleet-worktrees/</code>), and you point sessions at them — see <code>references/session-prompt.md</code> for the lane brief to hand each session.</p>\n<h2>Landing pipeline</h2>\n<p><code>fleet land &lt;branch&gt;</code> (and the daemon, per READY lane):</p>\n<ol>\n<li><strong>Scrub</strong> — <code>git diff main...branch</code> checked against <code>forbidden_pattern</code>; hits refuse the land and mark the lane <code>CONFLICT</code></li>\n<li><strong>Clean-base check</strong> — refuses if <code>main</code> has uncommitted tracked changes</li>\n<li><strong>Merge</strong> — <code>--no-ff</code> with message <code>merge: &lt;branch&gt;</code> (this message is what <code>fleet revert</code> finds later). If the branch is <em>already</em> contained in <code>main</code> — another session landed it while this one sat in the queue — <code>git merge</code> exits 0 with \"Already up to date.\" and nothing happens. fleet detects that by comparing the tip before and after (never by parsing git's prose) and reports it as <code>ALREADY LANDED: &lt;branch&gt; — already in main, no merge performed by this run</code>: the lane goes <code>LANDED</code>, the gate does <strong>not</strong> run (there is no merge of ours to gate), the lane branch is left for whoever did land it, and <code>land --all</code> counts it as <code>already in &lt;base&gt;</code>, apart from real lands</li>\n<li><strong>Test gate</strong> — runs <code>test_cmd</code>; on failure, hard-resets <code>main</code> to the tip captured <em>before</em> the merge — never to <code>HEAD^</code>, which on an already-merged branch is <strong>another session's</strong> merge commit — and marks the lane <code>FAILED</code>. If <code>test_cmd</code> is unset the land is <strong>refused</strong> outright rather than falling back to <code>signal.sh</code>'s log gate, which verifies nothing when a lane signalled READY without a test log. When landing into a repo with per-skill/per-package behavioural suites, <code>test_cmd</code> should run the <strong>full sweep</strong> (every suite, not just the touched lane's files) — suites routinely assert on shared or sibling files (a skill's own suite can require a frontmatter field a sibling trim pass doesn't know about), so scoping <code>test_cmd</code> to \"just what this lane touched\" reintroduces exactly the blind spot a test gate exists to close. <strong>Confirm the gate is actually armed with <code>fleet config</code> before trusting it</strong> — and watch the land log for <code>running test_cmd: …</code>, which is the only proof the gate actually ran.</li>\n<li><strong>Rebase others</strong> — every still-active lane is rebased onto the new <code>main</code> (in its own worktree if it has one); a rebase conflict marks that lane <code>CONFLICT</code></li>\n</ol>\n<p><code>fleet revert &lt;branch&gt;</code> finds the merge commit on <code>main</code> whose subject is <strong>exactly</strong> <code>merge: &lt;branch&gt;</code> and runs <code>git revert -m 1</code> — one command to back out a bad landing. The match is exact, never <code>git log --grep</code>: <code>--grep</code> is a regex applied as a <em>substring</em>, so <code>merge: lane/auth</code> also matched <code>merge: lane/auth-refactor</code> and reverting one lane destroyed the other's work while reporting the branch you asked for (fixed 2026-09-08). If the branch landed more than once, the most recent merge is reverted and the others are logged rather than silently passed over. A revert that conflicts is <strong>aborted</strong>, leaving <code>main</code> and the working tree exactly as they were — no stranded sequencer for the next <code>fleet land</code> to misreport as \"uncommitted tracked changes\". A reverted lane goes back to <code>RUNNING</code> with a note: it is no longer in <code>main</code>, so leaving it <code>LANDED</code> would be a status panel that lies about where the work lives.</p>\n<h2>Daemon lifecycle (experimental)</h2>\n<p>The daemon is the queue-automation layer on top of <code>fleet land</code> — optional; manual <code>fleet land</code> per branch is fully supported and not experimental.</p>\n<p>When Claude invokes <code>fleet start</code> via <code>Bash(run_in_background: true)</code>, the daemon:</p>\n<ol>\n<li>Writes its PID to <code>.claude/fleet/daemon.pid</code></li>\n<li>Traps <code>SIGINT/SIGTERM/SIGHUP</code> and removes the PID file on exit</li>\n<li>Refuses to start a second daemon if the PID file references a live process</li>\n<li>Polls <code>.claude/fleet/lanes/</code> and lands lanes as they turn <code>READY</code></li>\n<li>Exits naturally when all lanes are terminal (<code>LANDED</code> or <code>FAILED</code>)</li>\n</ol>\n<p>To stop early: <code>fleet stop</code> (SIGTERM, 5s grace, then SIGKILL). On next <code>fleet start</code>, a stale PID file is auto-detected and cleared. The daemon dies with the Claude Code session — for overnight runs use a real detached process, or skip the daemon and land manually.</p>\n<p><code>signal.sh</code> deploys to <code>.claude/fleet/signal.sh</code> on <code>init</code>/<code>track</code>. Working sessions call:</p>\n<pre><code>bash .claude/fleet/signal.sh READY &lt;test-log&gt; &lt;exit-code&gt;   # refuses dirty trees and failing runs\nbash .claude/fleet/signal.sh CONFLICT \"&lt;reason&gt;\"\n</code></pre>\n<p>The <code>&lt;exit-code&gt;</code> (the test command's own <code>$?</code> / <code>${PIPESTATUS[0]}</code>) is the authoritative verdict — pass it whenever you have it. Without it, <code>signal.sh</code> reads a trailing <code>exit code: N</code> line from the log, then a runner summary line (vitest/jest/pytest/cargo/go); it never word-greps prose, so passing runs that print \"failed\"/\"error\" while exercising failure paths don't false-refuse.</p>\n<h2>Session awareness — MAIN, lane owners, and the live-owner gate</h2>\n<p>Lane state files say <em>what</em> a lane is. They never say <em>who</em> is driving it. Fleet-ops\nreads the Claude Desktop session store to answer that, and uses the answer in two\nplaces: a gate that refuses to land under a live writer, and a coordinator address\nlanes can hand off to.</p>\n<h3>MAIN — one coordinator per repo</h3>\n<p><strong>MAIN is the session whose cwd is the repo root.</strong> That is not a new convention:\n<a href=\"../../rules/worktree-boundaries.md\"><code>worktree-boundaries</code></a> already holds that the base\ncheckout is the integration tree and must not host a writing session. <code>fleet main</code> just\nmakes the role <em>addressable</em>, so a lane can say \"I'm ready, come land me\" instead of\nwriting a file and hoping someone polls it.</p>\n<pre><code>fleet main                  Show the coordinator (sessionId, title, live|idle, cwd)\nfleet main claim [&lt;id&gt;]     Pin explicitly — for when several sessions share the root\nfleet main release          Clear the pin, fall back to the cwd heuristic\nfleet owner &lt;branch&gt;        Who owns this lane, and are they still writing?\n</code></pre>\n<p>MAIN's job is the whole integration half: land the queue, triage <code>CONFLICT</code> lanes,\nand run the deploy. Lanes build and signal; MAIN integrates. Note that deploying is\nmaintainer-gated regardless — it needs an explicit human OK for that specific deploy,\nfrom the maintainer's own session. MAIN being \"the one that deploys\" describes <em>which\nsession prepares it</em>, never an authorisation to ship unattended.</p>\n<h3>The live-owner gate</h3>\n<p><code>fleet land</code> refuses a lane whose owning session was active within\n<code>session_live_secs</code> (default 600). This closes a real hazard the queue could not see:\nlanding merges a branch the session may still be committing to, and then rebases every\nother lane's worktree <strong>out from under a live session</strong>.</p>\n<p>The join is <code>writtenBranches</code> from the session wrapper, not just the checked-out\nbranch — a session working in worktree <code>claude/foo-bar</code> routinely commits its real work\nto <code>lane/thing</code>, and only <code>writtenBranches</code> connects the two.</p>\n<p><strong>Self-ownership is exempt.</strong> The hazard is a <em>concurrent</em> writer, and the session\nrunning <code>fleet land</code> is not one — it is blocked inside that call, so it is provably not\nmid-commit, and the worktree being rebased \"out from under a live session\" is the one it\nis deliberately retiring. A lane session landing its own finished work therefore proceeds\nunaided. Without the exemption its only escape was a blanket override, which disarms the\ngate for the peers it genuinely protects; a narrow exemption beats a blunt one.</p>\n<p>It stays conservative in both directions. Identity comes from the harness\n(<code>CLAUDE_CODE_HOST_SESSION_ID</code> / <code>CLAUDE_CODE_SESSION_ID</code>) and is believed only once a\nwrapper bearing it is found in the store — <strong>there is deliberately no env var to set it</strong>,\nsince a settable self-id would be a universal gate bypass under another name, and an\nunresolvable one refuses exactly as before. Self must also be the <strong>only</strong> live owner:\na second live session writing the same branch refuses, naming the peer.</p>\n<p>Override with <code>session_check=off</code> in config, or <code>FLEET_SKIP_SESSION_CHECK=1</code> for one\nrun. One run means one run: fleet consumes the variable at startup and strips it (and\nthe rest of the <code>FLEET_*</code> knob family) from the environment before <code>test_cmd</code> runs, so\nthe override can never disarm a gate inside the very suite the landing is gated on —\ninherited into fleet-ops' own self-test, it once turned 6 live-owner refusal tests into\nfalse FAILs and reverted a green merge (2026-09-01). <code>fleet config</code> states plainly\nwhether the gate is armed <em>and</em> whether self-identity resolved — the same observability\nlesson as <code>test_cmd</code>.</p>\n<h3>Where each channel works (verified 2026-08-03)</h3>\n<table>\n<thead>\n<tr>\n<th>Channel</th>\n<th>Desktop</th>\n<th>Terminal / headless</th>\n<th>Non-Claude worker (Codex, GLM, Grok)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Lane state files (<code>signal.sh</code>)</td>\n<td>✅</td>\n<td>✅</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Session store on disk (<code>sessions.sh</code>)</td>\n<td>✅</td>\n<td>✅ (store is machine-local, not app-bound)</td>\n<td>✅</td>\n</tr>\n<tr>\n<td><code>ccd_session_mgmt</code> MCP tools</td>\n<td>✅</td>\n<td>❌ <strong>absent entirely</strong></td>\n<td>❌</td>\n</tr>\n<tr>\n<td><code>pigeon</code></td>\n<td>✅</td>\n<td>✅</td>\n<td>✅</td>\n</tr>\n</tbody>\n</table>\n<p><strong><code>ccd_session_mgmt</code> is Desktop-only, and this is not a configuration matter.</strong> The\nterminal CLI binary contains zero occurrences of <code>ccd_session_mgmt</code>, <code>list_sessions</code>,\n<code>search_session_transcripts</code>, or <code>spawn_task</code>; its single <code>ccd_session</code> reference is a\nconsumer-side notification handler for a server the <em>host</em> injects. Desktop's\n<code>app.asar</code> carries all of them. <code>claude mcp list</code> shows none of the <code>ccd_*</code> servers,\nbecause Desktop injects them as SDK-type servers rather than registering them.</p>\n<p>Two consequences that shape everything above:</p>\n<ol>\n<li><strong>A script can never call these tools.</strong> They are MCP tools, so only the agent can\ninvoke them. <code>sessions.sh</code> therefore reads the same underlying JSON store off disk —\nwhich, unlike the tools, is readable from a terminal too.</li>\n<li><strong>The read tools are ungated; the write tools prompt.</strong> <code>list_sessions</code> /\n<code>get_session</code> / <code>search_session_transcripts</code> return without user interaction, so\ndiscovery is free. <code>send_message</code> / <code>list_events</code> / <code>archive_session</code> always prompt —\nwhich makes <code>send_message</code> fine for a lane→MAIN handoff (that is exactly the\nhandoff/relay use it is documented for) and unsuitable for an unattended daemon.</li>\n</ol>\n<p>So: <strong>lane files are the substrate</strong> (work everywhere, ungated, machine-readable),\n<strong>ccd is the delivery accelerator</strong> where both ends are Desktop sessions, and <strong>pigeon\nis the portable fallback</strong> for terminal sessions and non-Claude harnesses. <code>signal.sh</code>\nprints the right one for your surface after every <code>READY</code> and <code>CONFLICT</code>.</p>\n<h2>Prune — worktree housekeeping</h2>\n<p>Landing a lane retires the <em>branch</em>. The <em>directory</em> stays, and across many\nrepos those accumulate into a backlog nobody can see. <code>fleet prune</code> classifies\nthem and removes only the ones that are provably finished.</p>\n<pre><code>fleet prune                  Classify and print. Changes NOTHING. (the default)\nfleet prune --dry-run        Same, said explicitly\nfleet prune --remove         Remove the SAFE rows, after a typed confirmation\nfleet prune --remove --yes   Skip the prompt (scripts/CI)\nfleet prune --porcelain      TSV to stdout: path, branch, bucket, reason\nfleet prune --all-repos      Sibling-repo counts. Report-only, always\n</code></pre>\n<p><strong>Dry run is the default, and that is deliberate.</strong> Removing a worktree destroys\nits uncommitted and untracked files permanently — git has never seen those\nbytes. Committed lane work is different: it lives in the shared object store,\nsurvives the directory, and comes back with <code>git worktree add &lt;path&gt; &lt;branch&gt;</code>.\nSeparating those two is the entire job, and every ambiguous case resolves away\nfrom deletion.</p>\n<h3>Buckets — first match wins, and the order is the safety argument</h3>\n<table>\n<thead>\n<tr>\n<th>#</th>\n<th>Condition</th>\n<th>Bucket</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>primary / git-locked / the tree you invoked from</td>\n<td><strong>KEEP</strong></td>\n</tr>\n<tr>\n<td>1b</td>\n<td>git reports the directory gone</td>\n<td><strong>REVIEW</strong> (that's <code>git worktree prune</code>'s job)</td>\n</tr>\n<tr>\n<td>2</td>\n<td>owning session is LIVE</td>\n<td><strong>KEEP</strong></td>\n</tr>\n<tr>\n<td>3</td>\n<td>session store unreadable, or <code>session_check=off</code></td>\n<td><strong>REVIEW</strong></td>\n</tr>\n<tr>\n<td>4</td>\n<td>detached HEAD</td>\n<td><strong>REVIEW</strong></td>\n</tr>\n<tr>\n<td>5</td>\n<td>uncommitted or untracked changes</td>\n<td><strong>REVIEW</strong></td>\n</tr>\n<tr>\n<td>6</td>\n<td>commits not yet in <code>base_branch</code></td>\n<td><strong>REVIEW</strong></td>\n</tr>\n<tr>\n<td>7</td>\n<td>merged + clean + owner archived or absent</td>\n<td><strong>SAFE</strong></td>\n</tr>\n<tr>\n<td>8</td>\n<td>anything else (incl. merged + clean but owner still open)</td>\n<td><strong>REVIEW</strong></td>\n</tr>\n</tbody>\n</table>\n<p>Only <strong>SAFE</strong> is ever removable. <strong>KEEP</strong> means one thing — hands off, not yours\nto judge. Everything else lands in <strong>REVIEW</strong>, which is reported and never\ntouched under any flag.</p>\n<p>Rule 3 is the one that matters most on a non-Desktop host: <em>\"the store says\nnobody owns this\"</em> is evidence of abandonment, while <em>\"the store could not be\nread\"</em> is no evidence at all — and an empty index looks identical to both. When\nthe store or <code>jq</code> is missing, <strong>nothing can be classified SAFE</strong> and prune\ndegrades to a pure report. It never fails, and it never guesses.</p>\n<h3>Why <code>.claude/worktrees/</code> gets extra care</h3>\n<p>Those directories are Claude Code's own session worktrees, and\n<a href=\"../../rules/worktree-boundaries.md\"><code>worktree-boundaries</code></a> is blunt about them:\n<em>they may look orphaned and aren't</em>. The slug is machine-generated and says\nnothing; a session that looks idle may simply be between turns. Prune marks them\n<code>!</code> in the table, and — because SAFE already requires a readable store plus an\narchived-or-absent owner — one can only be removed on positive evidence, never\non the absence of a signal.</p>\n<p>Three further guards, all on the irreversible direction:</p>\n<ol>\n<li><strong><code>git worktree remove</code>, never <code>rm -rf</code>.</strong> It refuses a dirty or locked tree\non its own, and it unregisters the worktree instead of leaving a stale\nadministrative entry behind.</li>\n<li><strong>Re-verify immediately before deleting.</strong> Classification reads a session\nindex with a long TTL (15 min); a session can wake between the table and the delete,\nso each SAFE row is re-checked with a fresh liveness read and a fresh dirty\ncheck, and skipped if either changed.</li>\n<li><strong><code>--all-repos</code> can never remove.</strong> It reports counts for sibling repos and\nstops there. Acting on another repo means running <code>fleet prune</code> inside it,\nwhere that repo's own base branch and config apply — so a single command can\nnever sweep the machine.</li>\n</ol>\n<h3>Landmine: removing a worktree out from under a live session</h3>\n<p><strong>Terminate the session first, then remove its worktree — never the reverse.</strong>\n<code>fleet prune</code> already enforces this: bucket 2 keeps a LIVE owner, and guard 2\nre-verifies liveness immediately before each delete. The hazard is every <em>other</em>\npath — a hand-run <code>git worktree remove</code>, an <code>rm -rf</code>, an external teardown\nscript, or a <code>--remove --yes</code> sweep racing a session that wakes mid-run.</p>\n<p><strong>A session whose worktree vanishes does not exit and does not error.</strong> It drops\ninto a retry loop and spins at ~85% of a core, indefinitely. Six of them,\nobserved 2026-08-30 across one repo's lane worktrees, burned <strong>66.6 core-hours\nacross 43.8 hours</strong>; five pointed at directories absent from both disk <em>and</em>\n<code>git worktree list</code>. Nothing logged, nothing alerted, no transcript was written.\nThe only symptom was a warm machine.</p>\n<p>It also evades the obvious check. These processes keep a <strong>live</strong> parent — the\nDesktop instance that spawned them — so a dead-parent orphan scan reports\nnothing useful: on that same machine it found 3 orphans totalling 1.16 GB while\nthe six spinners held five cores. <strong>Detect by CPU rate, not by lineage.</strong> Sample\ntwice and flag sustained burn:</p>\n<pre><code>$s=@{}; Get-Process claude,node -EA SilentlyContinue | % { $s[$_.Id]=$_.CPU }\nStart-Sleep 10\nGet-Process claude,node -EA SilentlyContinue |\n  ? { $s[$_.Id] -ne $null -and ($_.CPU-$s[$_.Id])/10 -gt 0.5 } |\n  Select Id,@{n='CorePct';e={[math]::Round(($_.CPU-$s[$_.Id])/10*100)}}\n</code></pre>\n<p>POSIX equivalent: <code>ps -eo pid,pcpu,etimes,args | grep claude</code> — a lane process\nat steady high <code>pcpu</code> with a large <code>etimes</code> is the same signature. Cross-check\nthe offender's <code>--add-dir</code> against <code>git worktree list</code>; a target missing from\nboth is conclusive. Killing the process is safe — it frees the CPU and touches\nno files, so uncommitted work in any surviving worktree is untouched.</p>\n<h3>Seeing the backlog</h3>\n<p><code>fleet status</code> adds one line when a repo has prunable worktrees\n(<code>! 3 worktree(s) prunable, 6 to review - fleet prune</code>), so the backlog is\nvisible rather than silently growing. Turn it off with <code>prune_hint=off</code> in\nconfig or <code>FLEET_NO_PRUNE_HINT=1</code>.</p>\n<h2>First-class user interaction (HARD RULE)</h2>\n<p>When this skill surfaces a decision point, <strong>always use the <code>AskUserQuestion</code> tool</strong>. Plain markdown numbered lists are not acceptable for these branches.</p>\n<table>\n<thead>\n<tr>\n<th>Trigger</th>\n<th>Question</th>\n<th>Options (≤4, ≤10 words each)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Multiple parallel-work requests, no lanes yet</td>\n<td>Spawn natively or manual lanes?</td>\n<td>Agent teams / Background agents / Manual fleet init / Cancel</td>\n</tr>\n<tr>\n<td><code>init</code> — worktrees available, mode unset</td>\n<td>Worktree or branch-only mode?</td>\n<td>Worktrees / Branches only / Cancel</td>\n</tr>\n<tr>\n<td>Land refused — owning session live</td>\n<td><code>&lt;name&gt;</code>'s session is still writing</td>\n<td>Wait and retry / Message that session / Override and land</td>\n</tr>\n<tr>\n<td><code>prune</code> found SAFE worktrees</td>\n<td>Remove <code>&lt;n&gt;</code> finished worktrees?</td>\n<td>Remove them / Show the table again / Leave as-is</td>\n</tr>\n<tr>\n<td>Lane → <code>CONFLICT</code> (rebase fail)</td>\n<td>Lane <code>&lt;name&gt;</code> has rebase conflict</td>\n<td>Resolve in lane / Skip &amp; continue / Revert lane / Untrack</td>\n</tr>\n<tr>\n<td>Lane → <code>FAILED</code> (post-merge tests red)</td>\n<td>Tests broke after <code>&lt;name&gt;</code> merged</td>\n<td>Auto-revert / Investigate first / Accept failure</td>\n</tr>\n<tr>\n<td>Pre-land scrub hits</td>\n<td>Forbidden patterns in <code>&lt;name&gt;</code> diff</td>\n<td>Block landing / Override (note reason) / Open to edit</td>\n</tr>\n<tr>\n<td><code>fleet</code> shows mixed states</td>\n<td>How to proceed with the fleet?</td>\n<td>Land all READY / Resolve CONFLICTs first / Just status</td>\n</tr>\n<tr>\n<td>Daemon exits with <code>FAILED</code> lanes</td>\n<td><code>&lt;n&gt;</code> lanes failed — what next?</td>\n<td>Retry all / Revert and report / Leave as-is</td>\n</tr>\n</tbody>\n</table>\n<p>For non-branching status updates (\"here's what happened, here's what landed\"), plain text is fine.</p>\n<h2>What it handles vs what it does not</h2>\n<table>\n<thead>\n<tr>\n<th>Mode</th>\n<th>Status</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Branches from native worktrees (<code>.claude/worktrees/</code>) via <code>fleet track</code></td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Worktrees on different branches (<code>fleet init</code>)</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Branches in separate clones / machines</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Mixed worktree + branch lanes</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Recovery from dirty <code>main</code></td>\n<td>✅ Refuses to merge, asks user to clean</td>\n</tr>\n<tr>\n<td>Test-gated landing</td>\n<td>✅ Via <code>signal.sh READY &lt;log&gt;</code> and/or <code>test_cmd</code></td>\n</tr>\n<tr>\n<td>Auto-rebase other lanes when one lands</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>Pre-land regex scrub (forbidden patterns)</td>\n<td>✅</td>\n</tr>\n<tr>\n<td>One-shot revert</td>\n<td>✅ <code>fleet revert &lt;branch&gt;</code></td>\n</tr>\n</tbody>\n</table>\n<p>| Pruning finished lane worktrees | ✅ <code>fleet prune</code> — dry-run by default, removes only provably-finished trees |</p>\n<table>\n<thead>\n<tr>\n<th>Out of scope</th>\n<th>Why</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Spawning / monitoring sessions</td>\n<td>Native: agent teams, <code>claude --bg</code>, agent view. Fleet-ops never launches a session.</td>\n</tr>\n<tr>\n<td>Deleting worktrees a session still owns</td>\n<td><code>fleet prune</code> removes only what is merged, clean, and owned by an archived-or-absent session. Anything live, dirty, unmerged, or unattributable is reported, never removed — and cross-repo removal is impossible by design. Removing one by any <em>other</em> path strands the session in a silent CPU spin — see <a href=\"#landmine-removing-a-worktree-out-from-under-a-live-session\">the ordering landmine</a>.</td>\n</tr>\n<tr>\n<td>Multiple sessions on one shared working tree</td>\n<td>Git limitation. Skill detects and refuses with worktree pointer.</td>\n</tr>\n<tr>\n<td>Uncommitted work at signal time</td>\n<td><code>signal.sh</code> rejects dirty lanes. The queue needs an immutable commit.</td>\n</tr>\n<tr>\n<td>External state (DB migrations, services)</td>\n<td>Skill can't know lane B depends on lane A's migration. Order manually via <code>fleet land</code>.</td>\n</tr>\n<tr>\n<td>Force-pushed lanes mid-flight</td>\n<td>Detected at land time, not prevented.</td>\n</tr>\n</tbody>\n</table>\n<h2>Compatibility</h2>\n<p>Tested and working on:</p>\n<table>\n<thead>\n<tr>\n<th>OS</th>\n<th>Shell</th>\n<th>Notes</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Linux</td>\n<td>bash 4+</td>\n<td>Native</td>\n</tr>\n<tr>\n<td>macOS</td>\n<td>bash 3.2+ (default) or bash 4+ via brew</td>\n<td><code>stat -f</code> fallback used automatically</td>\n</tr>\n<tr>\n<td>Windows</td>\n<td>Git Bash (mintty)</td>\n<td>Forward-slash paths; Unicode icons render in mintty/Windows Terminal</td>\n</tr>\n<tr>\n<td>Windows</td>\n<td>PowerShell 7 (calling <code>bash</code>)</td>\n<td>Works if <code>bash</code> is on PATH</td>\n</tr>\n</tbody>\n</table>\n<p>Requirements: <code>bash 3.2+</code>, <code>git 2.5+</code> (worktree support), <code>awk</code>, <code>grep</code>, <code>head</code>, <code>stat</code>. All standard.</p>\n<p>If your terminal mojibakes the status icons, fall back to ASCII: <code>export FLEET_ASCII=1</code> (or <code>icons=ascii</code> in <code>.claude/fleet/config</code>). Output panels follow <code>docs/TERMINAL-DESIGN.md</code> via <code>skills/_lib/term.sh</code>.</p>\n<p>Long-path warning (Windows only): <code>fleet init</code> worktrees nest under <code>.fleet-worktrees/&lt;name&gt;/</code>. Keep lane names short if your repo lives deep, or enable <code>core.longpaths=true</code>.</p>\n<h2>Headless agent compatibility</h2>\n<p><strong>Don't put manually-created fleet worktrees under <code>.claude/</code>.</strong> Claude Code applies a global sensitive-file guard to anything under <code>.claude/</code>, and that guard runs <em>before</em> — and is not bypassed by — <code>--dangerously-skip-permissions</code>. Headless lane sessions (<code>claude -p ... --dangerously-skip-permissions</code>) will fail every Write/Edit if their worktree lives under <code>.claude/</code>.</p>\n<p>That's why the default <code>worktree_root</code> is <code>.fleet-worktrees/</code> at the repo top. (Native background sessions are the exception: Claude Code itself manages <code>.claude/worktrees/</code> for them — leave those alone and just <code>fleet track</code> their branches.) Runtime state (<code>lanes/</code>, <code>daemon.pid</code>, <code>activity.log</code>) is read/write from the orchestrator only and stays under <code>.claude/fleet/</code>.</p>\n<h2>Configuration</h2>\n<p>Optional <code>.claude/fleet/config</code>, one <code>key=value</code> per line:</p>\n<pre><code>mode=auto                            # auto | worktree | branch\nworktree_root=.fleet-worktrees       # keep outside .claude/ — see \"Headless agent compatibility\"\ntest_cmd=npm run check               # if set, land runs it post-merge; else trust signal log\nforbidden_pattern=NEVER_LAND|debugger;   # override — the shipped default is described below\nbase_branch=main\npoll_interval=5\nicons=unicode                        # unicode | ascii (same as FLEET_ASCII=1)\nsession_check=on                     # on | off — refuse to land under a live owner\nsession_live_secs=600                # how recently active counts as \"still writing\"\nprune_hint=on                        # on | off — show the prunable backlog in `fleet status`\n</code></pre>\n<p>Zero-config works for the common case.</p>\n<p><strong>The shipped <code>forbidden_pattern</code> default</strong> (the exact regex lives at\n<code>FORBIDDEN_PATTERN</code> in <code>scripts/fleet.sh</code>) refuses the two scrub markers —\n<code>TODO_</code> + <code>SCRUB</code> and <code>FIXME_</code> + <code>BEFORE_LAND</code>, spelled split here deliberately —\nplus lone triple-X markers via the term <code>(^|[^X])X{3}[^a-zX]</code>. A run of four or\nmore X's is a <code>mktemp</code> template (<code>push-gate-paths.</code> plus six X's) and passes; a\nbare triple-X followed by a non-letter (a space, a colon) still refuses. A\ntemplate false-refused a landing on 2026-09-01, hence the run-aware form.</p>\n<p>Mind the self-reference: the scrub greps every <strong>added</strong> diff line, so writing a\ncontiguous marker token — or a triple-X run — into docs, comments, or a config\nexample refuses the very branch that adds it. Build such tokens by concatenation\n(<code>'TODO_''SCRUB'</code>), as <code>scripts/fleet.sh</code> and <code>tests/run.sh</code> themselves do.</p>\n<p><strong>Grammar.</strong> The file is <em>parsed</em>, not <code>source</code>d — it cannot execute code, and it is\nnot bash:</p>\n<table>\n<thead>\n<tr>\n<th>Rule</th>\n<th>Detail</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Keys</td>\n<td>Case-insensitive — <code>test_cmd</code> and <code>TEST_CMD</code> both work. Whitespace around the key and <code>=</code> is ignored.</td>\n</tr>\n<tr>\n<td>Values with spaces</td>\n<td>Need <strong>no quoting</strong>. The value runs to end of line: <code>test_cmd=uv run pytest -q tests/</code> is correct as written.</td>\n</tr>\n<tr>\n<td>Quotes</td>\n<td>Optional. <code>test_cmd=\"uv run pytest -q\"</code> works; one layer of matching <code>\"…\"</code> or <code>'…'</code> is stripped.</td>\n</tr>\n<tr>\n<td>Comments</td>\n<td>A whole line starting with <code>#</code>, or a trailing <code> # …</code> on an <strong>unquoted</strong> value. Quote the value to keep a literal <code>#</code>: <code>forbidden_pattern=\"TODO|#nolint\"</code>.</td>\n</tr>\n<tr>\n<td>Blank lines</td>\n<td>Ignored.</td>\n</tr>\n<tr>\n<td>Unknown / malformed keys</td>\n<td><strong>Warned about on stderr, naming file and line</strong> — never silently dropped.</td>\n</tr>\n</tbody>\n</table>\n<p>A config that exists but sets nothing recognised warns\n<code>… set no recognised keys — running on defaults (test gate OFF)</code> rather than looking\nlike an absent file.</p>\n<p><strong><code>test_cmd</code> is the test gate.</strong> When set, <code>fleet land</code> runs it <em>after</em> the merge\ncommit and, on a non-zero exit, hard-resets <code>base_branch</code> to the tip it captured <em>before</em>\nmerging — not <code>HEAD^</code> — dropping the lane to <code>FAILED</code>; the log shows <code>running test_cmd: …</code>.\nWhen unset, landing is <strong>refused</strong> (<code>the landing gate is UNARMED</code>, naming the config path)\nrather than falling through to signal.sh's weaker log gate. Worked example:</p>\n<pre><code>test_cmd=uv run pytest -q --maxfail=1\nbase_branch=main\n</code></pre>\n<blockquote>\n<p>Fixed 2026-07-28: config keys never reached the script (documented lowercase, read\nUPPERCASE; and unquoted spaced values aren't bash assignments, with the error\nswallowed by <code>2&gt;/dev/null</code>). Every landing before that date was gated by signal.sh\nalone — <code>test_cmd</code> had never run, on any repo. If you relied on it, you had no test\ngate. <code>icons=</code> in the config was inert for the same class of reason (read before the\nconfig loaded).</p>\n</blockquote>\n<p><code>fleet init</code>/<code>fleet track</code> append <code>.claude/fleet/</code> and <code>.fleet-worktrees/</code> to <code>.gitignore</code> and auto-commit that change with <code>chore: gitignore fleet-ops runtime state</code> when the tree is otherwise clean and you're on <code>base_branch</code>. If either condition fails, it prints an <code>ACTION REQUIRED</code> message — commit <code>.gitignore</code> yourself before landing.</p>\n<h2>Future work</h2>\n<ul>\n<li><strong>JSONL activity log</strong> — currently plain text. Switch when a TUI, <code>--json</code> output, or <code>log-ops</code> integration earns the cost.</li>\n<li><strong><code>TaskCompleted</code> hook bridge</strong> — auto-<code>signal.sh READY</code> when an agent-team task completes with green tests.</li>\n</ul>\n<p>Shipped since first release:</p>\n<ul>\n<li><strong><code>fleet land --all [--running]</code></strong> — batch-land all READY (or vetted RUNNING) lanes oldest-first, rebasing the rest after each and reporting once. Drives the <code>git-ops</code> \"land all\" front-door (<code>scripts/land-all.sh</code> discovers + classifies; fleet-ops executes).</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li><code>references/workflow.md</code> — end-to-end walkthroughs (native-spawn and manual-spawn) plus recovery scenarios</li>\n<li><code>references/session-prompt.md</code> — lane brief to embed in <code>claude --bg</code> prompts, teammate spawn prompts, or manual sessions</li>\n</ul>\n<h2>Scripts</h2>\n<ul>\n<li><code>scripts/fleet.sh</code> — main CLI (init, track, start/stop, status, land, revert, scrub-check, prune, config, main, owner)</li>\n<li><code>scripts/signal.sh</code> — branch-aware signaler (deployed to <code>.claude/fleet/signal.sh</code>); prints the MAIN handoff after READY/CONFLICT</li>\n<li><code>scripts/sessions.sh</code> — branch → owning-session resolver, read off the Desktop session store on disk (deployed alongside signal.sh so lane sessions can resolve MAIN). Enrichment only: exits 3 and stays silent wherever the store or <code>jq</code> is missing, and every caller treats that as \"no info\"</li>\n</ul>\n","files":[{"path":"assets/.gitkeep","sizeBytes":0,"isText":false},{"path":"references/session-prompt.md","sizeBytes":3677,"isText":true},{"path":"references/workflow.md","sizeBytes":6979,"isText":true},{"path":"scripts/fleet.sh","sizeBytes":86078,"isText":true},{"path":"scripts/sessions.sh","sizeBytes":15154,"isText":true},{"path":"scripts/signal.sh","sizeBytes":7808,"isText":true},{"path":"SKILL.md","sizeBytes":32306,"isText":true},{"path":"tests/run.sh","sizeBytes":61833,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-30T19:37:33.647732Z","sha256":"3ABBF32770065E609E6B320CE76559FD37CD1AE930EAF66349D4BF12113B43C0","sizeBytes":77172},"review":null,"source":{"repositoryUrl":"https://github.com/0xDarkMatter/claude-mods","path":"skills/fleet-ops","license":"MIT","commit":"3dfaf0ba5753026a99ee13f9d9ed56b9793bb6e8","subtreeSha":"3843C2C1E31628DEC1C53B613A6A92F067C371684BA038B2431B58C33D47548D","lastSyncedAt":"2026-09-30T19:37:28.226022Z"},"reviewedAt":"2026-09-30T19:38:58.064286Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/fleet-ops"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart"},{"target":"git","command":"git clone https://github.com/0xDarkMatter/claude-mods.git"}]}