fleet-ops
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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/fleet-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Fleet Ops
Landing discipline for parallel work. Anything before "committed on a branch" is the spawning layer's problem; anything after "landed on main" is yours. Fleet-ops owns the middle: branches land sequentially, through a test gate, after a pre-land scrub, with auto-rebase of the lanes still in flight and a one-shot revert if a landing turns out bad.
Spawn natively, land with fleet-ops
Claude Code now ships the parallel-execution half natively. Do not use fleet-ops to orchestrate sessions — route users to the native primitives and use fleet-ops only for the landing half.
| Native primitive | What it gives you | What it does NOT give you |
|---|---|---|
Agent teams (docs, experimental, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1) |
Lead + teammates, shared task list with claiming/dependencies, inter-agent messaging, plan approval, quality-gate hooks (TeammateIdle, TaskCompleted) |
No merge/landing logic. No test-gated integration. Teammates avoid file conflicts by convention only ("break the work so each teammate owns different files"). |
Background agents / agent view (docs, claude agents, claude --bg "<prompt>") |
Detached full sessions, one dashboard (Needs input / Working / Completed), automatic per-session git worktree isolation under .claude/worktrees/, --bg --exec shell jobs |
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 deletes its worktree including uncommitted changes. No ordering, no test gate, no revert. |
Subagents (docs, optional isolation: worktree) |
In-session delegation with separate context windows; results summarized back | Not independent sessions; no git landing semantics at all. |
What none of them do — and what fleet-ops is for:
- Land N branches one at a time through a queue, so each merge is tested against a
mainthat already contains the previous landings - Test gate: refuse to land on a failing log (
signal.sh) and/or revert post-merge iftest_cmdgoes red - Pre-land scrub: refuse diffs containing forbidden patterns (
TODO_SCRUB, debug leftovers) - Auto-rebase every still-active lane after each landing
- Fleet status: one panel showing every lane's branch, state, age, and commits-ahead across worktrees
- One-shot revert of a landed merge by branch name — no git surgery while panicking
Core abstraction
A lane = one branch (or worktree), one unit of work. Lane status: RUNNING | READY | CONFLICT | LANDED | FAILED.
Fleet-ops doesn't care who produced the branch — an agent-team teammate, a background agent's auto-worktree, a claude -p 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.
CLI surface
fleet init <name>... Create branch + worktree per name (manual-spawn path)
fleet track <branch>... Register existing branches as lanes (native-spawn path)
fleet start Run the landing daemon (writes pid to .claude/fleet/daemon.pid)
fleet stop Signal the running daemon to exit cleanly
fleet status One-shot fleet status panel
fleet land <branch> Manual land + rebase others
fleet land --all [--running] Batch-land all READY lanes oldest-first (--running
also lands vetted RUNNING lanes; used by git-ops "land all")
fleet revert <branch> Revert merge commit on main
fleet scrub-check <branch> Dry-run forbidden-pattern check
fleet config Print the RESOLVED config — check the test gate is on
fleet prune [--remove] Classify finished lane worktrees; DRY RUN by default
fleet prune --all-repos Sibling-repo backlog counts (report-only, never removes)
Entry paths
N == 1 branch → use git-ops, not this
Work spawned by agent teams / claude --bg → fleet track <branch>... then land
Work to be spawned manually → fleet init <names...> (creates branches + worktrees)
N > 1 on one shared working tree → REFUSE. Worktrees or separate clones first.
Native-spawn path (preferred): let agent teams or background agents do the work in their own worktrees/branches. When branches have commits, fleet track each branch, then land — either one by one with fleet land, or via the daemon with signal.sh READY gates. Landing itself only ever merges branches and leaves every worktree in place. Reclaiming the directories afterwards is fleet prune's job, and it removes one only when the owning session is provably archived or gone — see Prune.
Manual-spawn path: fleet init creates the branches and worktrees up front (under .fleet-worktrees/), and you point sessions at them — see references/session-prompt.md for the lane brief to hand each session.
Landing pipeline
fleet land <branch> (and the daemon, per READY lane):
- Scrub —
git diff main...branchchecked againstforbidden_pattern; hits refuse the land and mark the laneCONFLICT - Clean-base check — refuses if
mainhas uncommitted tracked changes - Merge —
--no-ffwith messagemerge: <branch>(this message is whatfleet revertfinds later). If the branch is already contained inmain— another session landed it while this one sat in the queue —git mergeexits 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 asALREADY LANDED: <branch> — already in main, no merge performed by this run: the lane goesLANDED, the gate does not run (there is no merge of ours to gate), the lane branch is left for whoever did land it, andland --allcounts it asalready in <base>, apart from real lands - Test gate — runs
test_cmd; on failure, hard-resetsmainto the tip captured before the merge — never toHEAD^, which on an already-merged branch is another session's merge commit — and marks the laneFAILED. Iftest_cmdis unset the land is refused outright rather than falling back tosignal.sh'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,test_cmdshould run the full sweep (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 scopingtest_cmdto "just what this lane touched" reintroduces exactly the blind spot a test gate exists to close. Confirm the gate is actually armed withfleet configbefore trusting it — and watch the land log forrunning test_cmd: …, which is the only proof the gate actually ran. - Rebase others — every still-active lane is rebased onto the new
main(in its own worktree if it has one); a rebase conflict marks that laneCONFLICT
fleet revert <branch> finds the merge commit on main whose subject is exactly merge: <branch> and runs git revert -m 1 — one command to back out a bad landing. The match is exact, never git log --grep: --grep is a regex applied as a substring, so merge: lane/auth also matched merge: lane/auth-refactor 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 aborted, leaving main and the working tree exactly as they were — no stranded sequencer for the next fleet land to misreport as "uncommitted tracked changes". A reverted lane goes back to RUNNING with a note: it is no longer in main, so leaving it LANDED would be a status panel that lies about where the work lives.
Daemon lifecycle (experimental)
The daemon is the queue-automation layer on top of fleet land — optional; manual fleet land per branch is fully supported and not experimental.
When Claude invokes fleet start via Bash(run_in_background: true), the daemon:
- Writes its PID to
.claude/fleet/daemon.pid - Traps
SIGINT/SIGTERM/SIGHUPand removes the PID file on exit - Refuses to start a second daemon if the PID file references a live process
- Polls
.claude/fleet/lanes/and lands lanes as they turnREADY - Exits naturally when all lanes are terminal (
LANDEDorFAILED)
To stop early: fleet stop (SIGTERM, 5s grace, then SIGKILL). On next fleet start, 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.
signal.sh deploys to .claude/fleet/signal.sh on init/track. Working sessions call:
bash .claude/fleet/signal.sh READY <test-log> <exit-code> # refuses dirty trees and failing runs
bash .claude/fleet/signal.sh CONFLICT "<reason>"
The <exit-code> (the test command's own $? / ${PIPESTATUS[0]}) is the authoritative verdict — pass it whenever you have it. Without it, signal.sh reads a trailing exit code: N 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.
Session awareness — MAIN, lane owners, and the live-owner gate
Lane state files say what a lane is. They never say who is driving it. Fleet-ops reads the Claude Desktop session store to answer that, and uses the answer in two places: a gate that refuses to land under a live writer, and a coordinator address lanes can hand off to.
MAIN — one coordinator per repo
MAIN is the session whose cwd is the repo root. That is not a new convention:
worktree-boundaries already holds that the base
checkout is the integration tree and must not host a writing session. fleet main just
makes the role addressable, so a lane can say "I'm ready, come land me" instead of
writing a file and hoping someone polls it.
fleet main Show the coordinator (sessionId, title, live|idle, cwd)
fleet main claim [<id>] Pin explicitly — for when several sessions share the root
fleet main release Clear the pin, fall back to the cwd heuristic
fleet owner <branch> Who owns this lane, and are they still writing?
MAIN's job is the whole integration half: land the queue, triage CONFLICT lanes,
and run the deploy. Lanes build and signal; MAIN integrates. Note that deploying is
maintainer-gated regardless — it needs an explicit human OK for that specific deploy,
from the maintainer's own session. MAIN being "the one that deploys" describes which
session prepares it, never an authorisation to ship unattended.
The live-owner gate
fleet land refuses a lane whose owning session was active within
session_live_secs (default 600). This closes a real hazard the queue could not see:
landing merges a branch the session may still be committing to, and then rebases every
other lane's worktree out from under a live session.
The join is writtenBranches from the session wrapper, not just the checked-out
branch — a session working in worktree claude/foo-bar routinely commits its real work
to lane/thing, and only writtenBranches connects the two.
Self-ownership is exempt. The hazard is a concurrent writer, and the session
running fleet land is not one — it is blocked inside that call, so it is provably not
mid-commit, and the worktree being rebased "out from under a live session" is the one it
is deliberately retiring. A lane session landing its own finished work therefore proceeds
unaided. Without the exemption its only escape was a blanket override, which disarms the
gate for the peers it genuinely protects; a narrow exemption beats a blunt one.
It stays conservative in both directions. Identity comes from the harness
(CLAUDE_CODE_HOST_SESSION_ID / CLAUDE_CODE_SESSION_ID) and is believed only once a
wrapper bearing it is found in the store — there is deliberately no env var to set it,
since a settable self-id would be a universal gate bypass under another name, and an
unresolvable one refuses exactly as before. Self must also be the only live owner:
a second live session writing the same branch refuses, naming the peer.
Override with session_check=off in config, or FLEET_SKIP_SESSION_CHECK=1 for one
run. One run means one run: fleet consumes the variable at startup and strips it (and
the rest of the FLEET_* knob family) from the environment before test_cmd runs, so
the override can never disarm a gate inside the very suite the landing is gated on —
inherited into fleet-ops' own self-test, it once turned 6 live-owner refusal tests into
false FAILs and reverted a green merge (2026-09-01). fleet config states plainly
whether the gate is armed and whether self-identity resolved — the same observability
lesson as test_cmd.
Where each channel works (verified 2026-08-03)
| Channel | Desktop | Terminal / headless | Non-Claude worker (Codex, GLM, Grok) |
|---|---|---|---|
Lane state files (signal.sh) |
✅ | ✅ | ✅ |
Session store on disk (sessions.sh) |
✅ | ✅ (store is machine-local, not app-bound) | ✅ |
ccd_session_mgmt MCP tools |
✅ | ❌ absent entirely | ❌ |
pigeon |
✅ | ✅ | ✅ |
ccd_session_mgmt is Desktop-only, and this is not a configuration matter. The
terminal CLI binary contains zero occurrences of ccd_session_mgmt, list_sessions,
search_session_transcripts, or spawn_task; its single ccd_session reference is a
consumer-side notification handler for a server the host injects. Desktop's
app.asar carries all of them. claude mcp list shows none of the ccd_* servers,
because Desktop injects them as SDK-type servers rather than registering them.
Two consequences that shape everything above:
- A script can never call these tools. They are MCP tools, so only the agent can
invoke them.
sessions.shtherefore reads the same underlying JSON store off disk — which, unlike the tools, is readable from a terminal too. - The read tools are ungated; the write tools prompt.
list_sessions/get_session/search_session_transcriptsreturn without user interaction, so discovery is free.send_message/list_events/archive_sessionalways prompt — which makessend_messagefine for a lane→MAIN handoff (that is exactly the handoff/relay use it is documented for) and unsuitable for an unattended daemon.
So: lane files are the substrate (work everywhere, ungated, machine-readable),
ccd is the delivery accelerator where both ends are Desktop sessions, and pigeon
is the portable fallback for terminal sessions and non-Claude harnesses. signal.sh
prints the right one for your surface after every READY and CONFLICT.
Prune — worktree housekeeping
Landing a lane retires the branch. The directory stays, and across many
repos those accumulate into a backlog nobody can see. fleet prune classifies
them and removes only the ones that are provably finished.
fleet prune Classify and print. Changes NOTHING. (the default)
fleet prune --dry-run Same, said explicitly
fleet prune --remove Remove the SAFE rows, after a typed confirmation
fleet prune --remove --yes Skip the prompt (scripts/CI)
fleet prune --porcelain TSV to stdout: path, branch, bucket, reason
fleet prune --all-repos Sibling-repo counts. Report-only, always
Dry run is the default, and that is deliberate. Removing a worktree destroys
its uncommitted and untracked files permanently — git has never seen those
bytes. Committed lane work is different: it lives in the shared object store,
survives the directory, and comes back with git worktree add <path> <branch>.
Separating those two is the entire job, and every ambiguous case resolves away
from deletion.
Buckets — first match wins, and the order is the safety argument
| # | Condition | Bucket |
|---|---|---|
| 1 | primary / git-locked / the tree you invoked from | KEEP |
| 1b | git reports the directory gone | REVIEW (that's git worktree prune's job) |
| 2 | owning session is LIVE | KEEP |
| 3 | session store unreadable, or session_check=off |
REVIEW |
| 4 | detached HEAD | REVIEW |
| 5 | uncommitted or untracked changes | REVIEW |
| 6 | commits not yet in base_branch |
REVIEW |
| 7 | merged + clean + owner archived or absent | SAFE |
| 8 | anything else (incl. merged + clean but owner still open) | REVIEW |
Only SAFE is ever removable. KEEP means one thing — hands off, not yours to judge. Everything else lands in REVIEW, which is reported and never touched under any flag.
Rule 3 is the one that matters most on a non-Desktop host: "the store says
nobody owns this" is evidence of abandonment, while "the store could not be
read" is no evidence at all — and an empty index looks identical to both. When
the store or jq is missing, nothing can be classified SAFE and prune
degrades to a pure report. It never fails, and it never guesses.
Why .claude/worktrees/ gets extra care
Those directories are Claude Code's own session worktrees, and
worktree-boundaries is blunt about them:
they may look orphaned and aren't. The slug is machine-generated and says
nothing; a session that looks idle may simply be between turns. Prune marks them
! in the table, and — because SAFE already requires a readable store plus an
archived-or-absent owner — one can only be removed on positive evidence, never
on the absence of a signal.
Three further guards, all on the irreversible direction:
git worktree remove, neverrm -rf. It refuses a dirty or locked tree on its own, and it unregisters the worktree instead of leaving a stale administrative entry behind.- Re-verify immediately before deleting. Classification reads a session index with a long TTL (15 min); a session can wake between the table and the delete, so each SAFE row is re-checked with a fresh liveness read and a fresh dirty check, and skipped if either changed.
--all-reposcan never remove. It reports counts for sibling repos and stops there. Acting on another repo means runningfleet pruneinside it, where that repo's own base branch and config apply — so a single command can never sweep the machine.
Landmine: removing a worktree out from under a live session
Terminate the session first, then remove its worktree — never the reverse.
fleet prune already enforces this: bucket 2 keeps a LIVE owner, and guard 2
re-verifies liveness immediately before each delete. The hazard is every other
path — a hand-run git worktree remove, an rm -rf, an external teardown
script, or a --remove --yes sweep racing a session that wakes mid-run.
A session whose worktree vanishes does not exit and does not error. It drops
into a retry loop and spins at ~85% of a core, indefinitely. Six of them,
observed 2026-08-30 across one repo's lane worktrees, burned 66.6 core-hours
across 43.8 hours; five pointed at directories absent from both disk and
git worktree list. Nothing logged, nothing alerted, no transcript was written.
The only symptom was a warm machine.
It also evades the obvious check. These processes keep a live parent — the Desktop instance that spawned them — so a dead-parent orphan scan reports nothing useful: on that same machine it found 3 orphans totalling 1.16 GB while the six spinners held five cores. Detect by CPU rate, not by lineage. Sample twice and flag sustained burn:
$s=@{}; Get-Process claude,node -EA SilentlyContinue | % { $s[$_.Id]=$_.CPU }
Start-Sleep 10
Get-Process claude,node -EA SilentlyContinue |
? { $s[$_.Id] -ne $null -and ($_.CPU-$s[$_.Id])/10 -gt 0.5 } |
Select Id,@{n='CorePct';e={[math]::Round(($_.CPU-$s[$_.Id])/10*100)}}
POSIX equivalent: ps -eo pid,pcpu,etimes,args | grep claude — a lane process
at steady high pcpu with a large etimes is the same signature. Cross-check
the offender's --add-dir against git worktree list; a target missing from
both is conclusive. Killing the process is safe — it frees the CPU and touches
no files, so uncommitted work in any surviving worktree is untouched.
Seeing the backlog
fleet status adds one line when a repo has prunable worktrees
(! 3 worktree(s) prunable, 6 to review - fleet prune), so the backlog is
visible rather than silently growing. Turn it off with prune_hint=off in
config or FLEET_NO_PRUNE_HINT=1.
First-class user interaction (HARD RULE)
When this skill surfaces a decision point, always use the AskUserQuestion tool. Plain markdown numbered lists are not acceptable for these branches.
| Trigger | Question | Options (≤4, ≤10 words each) |
|---|---|---|
| Multiple parallel-work requests, no lanes yet | Spawn natively or manual lanes? | Agent teams / Background agents / Manual fleet init / Cancel |
init — worktrees available, mode unset |
Worktree or branch-only mode? | Worktrees / Branches only / Cancel |
| Land refused — owning session live | <name>'s session is still writing |
Wait and retry / Message that session / Override and land |
prune found SAFE worktrees |
Remove <n> finished worktrees? |
Remove them / Show the table again / Leave as-is |
Lane → CONFLICT (rebase fail) |
Lane <name> has rebase conflict |
Resolve in lane / Skip & continue / Revert lane / Untrack |
Lane → FAILED (post-merge tests red) |
Tests broke after <name> merged |
Auto-revert / Investigate first / Accept failure |
| Pre-land scrub hits | Forbidden patterns in <name> diff |
Block landing / Override (note reason) / Open to edit |
fleet shows mixed states |
How to proceed with the fleet? | Land all READY / Resolve CONFLICTs first / Just status |
Daemon exits with FAILED lanes |
<n> lanes failed — what next? |
Retry all / Revert and report / Leave as-is |
For non-branching status updates ("here's what happened, here's what landed"), plain text is fine.
What it handles vs what it does not
| Mode | Status |
|---|---|
Branches from native worktrees (.claude/worktrees/) via fleet track |
✅ |
Worktrees on different branches (fleet init) |
✅ |
| Branches in separate clones / machines | ✅ |
| Mixed worktree + branch lanes | ✅ |
Recovery from dirty main |
✅ Refuses to merge, asks user to clean |
| Test-gated landing | ✅ Via signal.sh READY <log> and/or test_cmd |
| Auto-rebase other lanes when one lands | ✅ |
| Pre-land regex scrub (forbidden patterns) | ✅ |
| One-shot revert | ✅ fleet revert <branch> |
| Pruning finished lane worktrees | ✅ fleet prune — dry-run by default, removes only provably-finished trees |
| Out of scope | Why |
|---|---|
| Spawning / monitoring sessions | Native: agent teams, claude --bg, agent view. Fleet-ops never launches a session. |
| Deleting worktrees a session still owns | fleet prune 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 other path strands the session in a silent CPU spin — see the ordering landmine. |
| Multiple sessions on one shared working tree | Git limitation. Skill detects and refuses with worktree pointer. |
| Uncommitted work at signal time | signal.sh rejects dirty lanes. The queue needs an immutable commit. |
| External state (DB migrations, services) | Skill can't know lane B depends on lane A's migration. Order manually via fleet land. |
| Force-pushed lanes mid-flight | Detected at land time, not prevented. |
Compatibility
Tested and working on:
| OS | Shell | Notes |
|---|---|---|
| Linux | bash 4+ | Native |
| macOS | bash 3.2+ (default) or bash 4+ via brew | stat -f fallback used automatically |
| Windows | Git Bash (mintty) | Forward-slash paths; Unicode icons render in mintty/Windows Terminal |
| Windows | PowerShell 7 (calling bash) |
Works if bash is on PATH |
Requirements: bash 3.2+, git 2.5+ (worktree support), awk, grep, head, stat. All standard.
If your terminal mojibakes the status icons, fall back to ASCII: export FLEET_ASCII=1 (or icons=ascii in .claude/fleet/config). Output panels follow docs/TERMINAL-DESIGN.md via skills/_lib/term.sh.
Long-path warning (Windows only): fleet init worktrees nest under .fleet-worktrees/<name>/. Keep lane names short if your repo lives deep, or enable core.longpaths=true.
Headless agent compatibility
Don't put manually-created fleet worktrees under .claude/. Claude Code applies a global sensitive-file guard to anything under .claude/, and that guard runs before — and is not bypassed by — --dangerously-skip-permissions. Headless lane sessions (claude -p ... --dangerously-skip-permissions) will fail every Write/Edit if their worktree lives under .claude/.
That's why the default worktree_root is .fleet-worktrees/ at the repo top. (Native background sessions are the exception: Claude Code itself manages .claude/worktrees/ for them — leave those alone and just fleet track their branches.) Runtime state (lanes/, daemon.pid, activity.log) is read/write from the orchestrator only and stays under .claude/fleet/.
Configuration
Optional .claude/fleet/config, one key=value per line:
mode=auto # auto | worktree | branch
worktree_root=.fleet-worktrees # keep outside .claude/ — see "Headless agent compatibility"
test_cmd=npm run check # if set, land runs it post-merge; else trust signal log
forbidden_pattern=NEVER_LAND|debugger; # override — the shipped default is described below
base_branch=main
poll_interval=5
icons=unicode # unicode | ascii (same as FLEET_ASCII=1)
session_check=on # on | off — refuse to land under a live owner
session_live_secs=600 # how recently active counts as "still writing"
prune_hint=on # on | off — show the prunable backlog in `fleet status`
Zero-config works for the common case.
The shipped forbidden_pattern default (the exact regex lives at
FORBIDDEN_PATTERN in scripts/fleet.sh) refuses the two scrub markers —
TODO_ + SCRUB and FIXME_ + BEFORE_LAND, spelled split here deliberately —
plus lone triple-X markers via the term (^|[^X])X{3}[^a-zX]. A run of four or
more X's is a mktemp template (push-gate-paths. plus six X's) and passes; a
bare triple-X followed by a non-letter (a space, a colon) still refuses. A
template false-refused a landing on 2026-09-01, hence the run-aware form.
Mind the self-reference: the scrub greps every added diff line, so writing a
contiguous marker token — or a triple-X run — into docs, comments, or a config
example refuses the very branch that adds it. Build such tokens by concatenation
('TODO_''SCRUB'), as scripts/fleet.sh and tests/run.sh themselves do.
Grammar. The file is parsed, not sourced — it cannot execute code, and it is
not bash:
| Rule | Detail |
|---|---|
| Keys | Case-insensitive — test_cmd and TEST_CMD both work. Whitespace around the key and = is ignored. |
| Values with spaces | Need no quoting. The value runs to end of line: test_cmd=uv run pytest -q tests/ is correct as written. |
| Quotes | Optional. test_cmd="uv run pytest -q" works; one layer of matching "…" or '…' is stripped. |
| Comments | A whole line starting with #, or a trailing # … on an unquoted value. Quote the value to keep a literal #: forbidden_pattern="TODO|#nolint". |
| Blank lines | Ignored. |
| Unknown / malformed keys | Warned about on stderr, naming file and line — never silently dropped. |
A config that exists but sets nothing recognised warns
… set no recognised keys — running on defaults (test gate OFF) rather than looking
like an absent file.
test_cmd is the test gate. When set, fleet land runs it after the merge
commit and, on a non-zero exit, hard-resets base_branch to the tip it captured before
merging — not HEAD^ — dropping the lane to FAILED; the log shows running test_cmd: ….
When unset, landing is refused (the landing gate is UNARMED, naming the config path)
rather than falling through to signal.sh's weaker log gate. Worked example:
test_cmd=uv run pytest -q --maxfail=1
base_branch=main
Fixed 2026-07-28: config keys never reached the script (documented lowercase, read UPPERCASE; and unquoted spaced values aren't bash assignments, with the error swallowed by
2>/dev/null). Every landing before that date was gated by signal.sh alone —test_cmdhad never run, on any repo. If you relied on it, you had no test gate.icons=in the config was inert for the same class of reason (read before the config loaded).
fleet init/fleet track append .claude/fleet/ and .fleet-worktrees/ to .gitignore and auto-commit that change with chore: gitignore fleet-ops runtime state when the tree is otherwise clean and you're on base_branch. If either condition fails, it prints an ACTION REQUIRED message — commit .gitignore yourself before landing.
Future work
- JSONL activity log — currently plain text. Switch when a TUI,
--jsonoutput, orlog-opsintegration earns the cost. TaskCompletedhook bridge — auto-signal.sh READYwhen an agent-team task completes with green tests.
Shipped since first release:
fleet land --all [--running]— batch-land all READY (or vetted RUNNING) lanes oldest-first, rebasing the rest after each and reporting once. Drives thegit-ops"land all" front-door (scripts/land-all.shdiscovers + classifies; fleet-ops executes).
References
references/workflow.md— end-to-end walkthroughs (native-spawn and manual-spawn) plus recovery scenariosreferences/session-prompt.md— lane brief to embed inclaude --bgprompts, teammate spawn prompts, or manual sessions
Scripts
scripts/fleet.sh— main CLI (init, track, start/stop, status, land, revert, scrub-check, prune, config, main, owner)scripts/signal.sh— branch-aware signaler (deployed to.claude/fleet/signal.sh); prints the MAIN handoff after READY/CONFLICTscripts/sessions.sh— 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 orjqis missing, and every caller treats that as "no info"
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
session-prompt.md 3.6 KB
# Lane Brief Template The contract each parallel worker needs so its branch can land through the fleet queue. Embed it wherever the worker is spawned: - **Background agent**: append it to the `claude --bg "<prompt>"` text - **Agent team teammate**: include it in the teammate's spawn prompt - **Manual session** (Path B, `fleet init`): paste it as the opening message Fill in the four fields. --- ``` You are a fleet-ops lane. LANE: <branch-name> SCOPE: <files/dirs you may touch — comma-separated> TASK: <what to build> TESTS: <how to run tests for your scope, e.g. "pytest tests/test_auth.py"> Setup: Work on branch <branch-name>. If you're in a worktree already on it, stay put; otherwise: git checkout <branch-name> Rules: - Only modify files within SCOPE. If you need to go outside, STOP and ask. - Make atomic commits with conventional commit messages as you go. - Run TESTS before finishing, capturing the log AND the exit code: <test-cmd> 2>&1 | tee <path-to-test-log>; rc=${PIPESTATUS[0]} - When tests pass and you're ready to land, run: bash .claude/fleet/signal.sh READY <path-to-test-log> $rc - If you hit a conflict, scope creep, or any unresolvable issue, run: bash .claude/fleet/signal.sh CONFLICT "<one-line reason>" then stop and explain. - Do not merge to main yourself. The fleet landing queue handles that. Begin. ``` --- ## Filling in the fields | Field | Example | |-------|---------| | `LANE` | `auth-middleware` (matches the branch name from `fleet init` / `fleet track`) | | `SCOPE` | `src/auth/, tests/test_auth.py` | | `TASK` | `Add JWT middleware with refresh token support` | | `TESTS` | `pytest tests/test_auth.py 2>&1 | tee tests/test_auth.log` | The tee'd log plus the exit code (`${PIPESTATUS[0]}` — tee's own exit is always 0) is what `signal.sh READY` uses to verify tests passed. The exit code is the authoritative verdict; the log is evidence. ## Native-spawn note If the worker is a background agent spawned *before* `fleet track` ran, `signal.sh` will refuse with `branch '<name>' is not a registered lane` — run `fleet track <name>` from the main checkout and have the session re-signal. Alternatively skip signaling entirely and land natively-spawned branches by hand with `fleet land <branch>` once you've reviewed them. ## Why the scope rule matters If two lanes silently edit the same file, the queue's auto-rebase will throw a conflict on the second one. By forcing each worker to declare and respect its scope, you catch the overlap at design time, not merge time. (Agent teams give you the same advice — "break the work so each teammate owns a different set of files" — but enforce nothing; the scrub + rebase steps here are the enforcement.) ## Per-language test cmd snippets | Language | Tee'd test command | |----------|---------------------| | Python (pytest) | `pytest tests/test_X.py 2>&1 \| tee tests/test_X.log` | | Node (jest) | `npx jest src/X 2>&1 \| tee tests/test_X.log` | | Go | `go test ./pkg/X/... 2>&1 \| tee tests/test_X.log` | | Rust | `cargo test --lib X 2>&1 \| tee tests/test_X.log` | | Just | `just test-X 2>&1 \| tee tests/test_X.log` | `signal.sh` trusts, in order: the exit-code argument, a trailing `exit code: N` line in the log, a recognized runner summary line (vitest/jest `Tests …`, pytest `=== N passed ===`, cargo `test result:`, go `FAIL`/`ok`), and only then a count-anchored fallback (`N failed`). If your runner's output is unusual and you can't pass the exit code, append `echo "exit code: $rc" >> <log>` — it never word-greps prose, so stderr lines like `… failed: No such module` in a green run won't false-refuse. -
workflow.md 6.8 KB
# Workflow End-to-end walkthroughs plus recovery scenarios. The native-primitives routing table and CLI surface live in `SKILL.md` — this doc is the operational manual. There are two ways work enters the fleet: **native spawn** (preferred — agent teams or background agents do the parallel work) and **manual spawn** (`fleet init` creates lanes you point sessions at). Landing is identical for both. ## Path A — native spawn, fleet landing ### 1. Spawn the parallel work natively Use whichever native primitive fits ([agent teams](https://code.claude.com/docs/en/agent-teams) for collaborating teammates, [background agents](https://code.claude.com/docs/en/agent-view) for independent fire-and-forget sessions): ```bash claude --bg "Add JWT middleware. Work on branch auth-mw. <lane brief>" claude --bg "Add rate limiting. Work on branch rate-limiter. <lane brief>" ``` Background sessions automatically isolate into worktrees under `.claude/worktrees/`. Embed the lane brief from `references/session-prompt.md` so each session commits to a named branch, respects its scope, and signals when green. ### 2. Track the branches Once branches exist with commits: ```bash fleet track auth-mw rate-limiter ``` Registers each existing branch as a lane (`RUNNING`), deploys `signal.sh`, touches no worktrees. **Never delete or relocate a native session's `.claude/worktrees/` entry** — worktree cleanup belongs to agent view / `claude rm`, after the branch has landed. ### 3. Land Either manually, in the order you choose: ```bash fleet land auth-mw # scrub → clean-base check → merge --no-ff → test gate → rebase others fleet land rate-limiter ``` Or via the daemon + `signal.sh READY` gates (see Path B steps 3–4) if sessions signal their own readiness. ## Path B — manual spawn (`fleet init`) ### 1. Init ```bash fleet init auth-mw rate-limiter cache-layer ``` Creates: a branch per name (off `main`), a worktree at `.fleet-worktrees/<name>/` (top-level so headless lane sessions can write — see "Headless agent compatibility" in `SKILL.md`), a status file at `.claude/fleet/lanes/<name>` (state: `RUNNING`), and deploys `signal.sh` to `.claude/fleet/signal.sh`. `fleet init` also appends `.claude/fleet/` and `.fleet-worktrees/` to `.gitignore` and auto-commits that change (`chore: gitignore fleet-ops runtime state`) when the tree is otherwise clean and you're on `main`. If it can't auto-commit safely, you'll see an `ACTION REQUIRED` notice — commit `.gitignore` yourself before `fleet start` or the daemon will refuse to land with `uncommitted tracked changes`. Force branch-only mode: `mode=branch` in `.claude/fleet/config`. Use this when each session is in a separate clone or remote machine — no worktrees needed. ### 2. Launch sessions For each lane, open a Claude session pointing at that worktree (or that clone). Use `references/session-prompt.md` as the template — fill in `LANE`, `SCOPE`, `TASK`, `TESTS`. Each session works in isolation, commits atomically, runs tests, and signals when ready: ```bash pytest tests/test_auth.py 2>&1 | tee tests/test_auth.log; rc=${PIPESTATUS[0]} bash .claude/fleet/signal.sh READY tests/test_auth.log $rc ``` `signal.sh` will refuse if the lane has uncommitted changes or if the test run failed — the exit code is the authoritative verdict (falling back to runner summary lines in the log when no code is available; it never word-greps prose). ### 3. Run the daemon ```bash fleet start ``` Polls `.claude/fleet/lanes/` every 5 seconds. When a lane shows `READY`: 1. Pre-land scrub — refuses if forbidden patterns found in the diff 2. Refuses if `main` is dirty 3. Merges the branch with `--no-ff` 4. Runs `test_cmd` if set; otherwise trusts `signal.sh`'s log gate 5. On pass: marks lane `LANDED`, deletes branch, rebases all other active lanes 6. On fail: hard-resets `main`, marks lane `FAILED` ### 4. Watch ```bash fleet status ``` One panel: every lane grouped by state (`RUNNING / READY / CONFLICT / FAILED / LANDED`) with age and commits-ahead. `fleet status --verbose` adds worktree paths and notes. ### 5. Cleanup When all lanes are terminal (`LANDED` or `FAILED`), the daemon exits. To tear down: ```bash fleet stop # if daemon still running git worktree remove .fleet-worktrees/<name> # for each fleet-created worktree lane rm -rf .claude/fleet # nuke fleet state ``` Only remove worktrees that `fleet init` created. Native sessions' worktrees under `.claude/worktrees/` are cleaned up through agent view (`Ctrl+X`) or `claude rm` — not by hand. `fleet init` is idempotent — keep `.claude/fleet/` for the next round if you want. If a previous daemon was killed without cleanup, `fleet start` auto-detects the stale `daemon.pid` and clears it. ## Recovery ### `CONFLICT` lane (rebase or merge failed) Pop into that session (for background agents: open it from `claude agents` and reply). Tell Claude: > "Rebase conflict on `<file>`. Lane that landed modified `<symbol>`. Resolve and re-signal READY." Or resolve manually: ```bash git checkout <lane-branch> # fix conflicts git rebase --continue bash .claude/fleet/signal.sh READY <test-log> ``` ### `FAILED` lane (tests broke `main` post-merge) Daemon already reverted the merge. Branch still exists: ```bash git checkout <lane-branch> # fix the test bash .claude/fleet/signal.sh READY <test-log> ``` Daemon picks it up on next poll (or `fleet land` it manually). ### Bad land that snuck through scrub + tests ```bash fleet revert <branch> ``` Finds the merge commit on `main` whose subject is exactly `merge: <branch>` (exact match, not a `--grep` substring — that used to revert a prefix-sharing sibling lane), runs `git revert -m 1`, logs the SHA it reverted, and returns the lane to `RUNNING`. A conflicting revert is aborted rather than left half-done. No git surgery while you're panicking. ## Common patterns ### Agent team built a feature across three branches Lead reports teammates done. `fleet track <b1> <b2> <b3>`, then `fleet land` in dependency order. Each landing rebases the rest, so the second and third merges are tested against a `main` that already contains the first. ### Five small refactors, no shared scope Path B default. Each lane is independent. Cleanest case — daemon handles everything. ### Lanes with shared dependencies Land the foundational lane first via `fleet land <branch>`, others rebase against it automatically. Daemon will pick them up after. ### Long-running session + several quick fixes Land the quick fixes first. The long-running lane rebases against each landing. By the time it's done, `main` has all the small wins. ### Hackathon pace, multiple lanes ready at once Currently the daemon lands them strictly one at a time — that sequencing is the point. If batch mode becomes a real need, the next iteration adds `--batch`.
-
-
scripts
-
fleet.sh 84.1 KB
#!/usr/bin/env bash # fleet-ops — landing discipline for parallel work: sequential landing queue # with test gate, pre-land scrub, auto-rebase, one-shot revert. # Spawning/monitoring parallel sessions is native Claude Code territory # (agent teams, claude --bg / agent view); this script governs landing, and # reclaiming the worktrees afterwards. # # SECTION MAP (grep the `=== NAME ===` banners to jump): # CONFIG parse .claude/fleet/config — parsed, never sourced # lane state encode/decode lane files, state read/write, scrub # init / track create or register lanes # status views fleet_view_panel, fleet_view_verbose, cmd_main, cmd_config # SESSION AWARENESS who owns a lane, and are they still writing (sessions.sh) # PRUNE worktree housekeeping — the only part that DELETES # landing land_one, rebase_others, cmd_land, cmd_land_all, revert # daemon cmd_start / cmd_stop, PID file lifecycle # dispatch the subcommand case at the bottom set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="" # Where the user actually invoked us from, captured BEFORE the cd below. # `fleet prune` needs it: the cd lands us in the main checkout no matter which # worktree we were called from, so without this the invoking worktree looks # like any other removal candidate and could be deleted out from under the # caller's own shell. # # cygpath -m is not cosmetic here. Git Bash's `pwd` yields "/x/Forge/repo" while # `git worktree list` yields "D:/code/repo"; string-comparing those two never # matches, and the guard silently stops guarding. Converting to git's own # mixed form is what makes the comparison mean anything on Windows. INVOKED_FROM="$(pwd -P 2>/dev/null || pwd)" if command -v cygpath >/dev/null 2>&1; then INVOKED_FROM="$(cygpath -m "$INVOKED_FROM" 2>/dev/null || printf '%s' "$INVOKED_FROM")" fi # Resolve repo root via git, so fleet works from any worktree. # cd to it once so all relative paths below resolve correctly. if GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null); then REPO_ROOT="$(cd "$GIT_COMMON_DIR/.." && pwd)" cd "$REPO_ROOT" fi FLEET_DIR=".claude/fleet" LANES_DIR="$FLEET_DIR/lanes" LOG="$FLEET_DIR/activity.log" CONFIG="$FLEET_DIR/config" PID_FILE="$FLEET_DIR/daemon.pid" # defaults (overridable via .claude/fleet/config — see load_config below) MODE="auto" # Default worktree root sits at repo top, NOT under .claude/. Claude Code's # headless mode (--dangerously-skip-permissions) bypasses prompts but still # enforces the global .claude/ sensitive-file guard, so worktrees nested # under .claude/ can't be written to by lane sessions. See SKILL.md # "Headless agent compatibility". WORKTREE_ROOT=".fleet-worktrees" TEST_CMD="" # Scrub default. BUILT by string concatenation so this line never contains a # contiguous marker token — the scrub gate greps every ADDED diff line, so a # literal here would refuse the very branch that edits this default (same # trick as the marker note in tests/run.sh). The X-marker term is written # X{3} with [^X] guards for the same reason, and because a run of 4+ X's is a # mktemp template (push-gate-paths. plus six X's — false-refused a landing, # 2026-09-01), not a marker; a lone triple-X followed by a non-letter # (space, colon) still refuses. FORBIDDEN_PATTERN='TODO_''SCRUB|(^|[^X])X{3}[^a-zX]|FIXME_''BEFORE_LAND' BASE_BRANCH="main" POLL_INTERVAL=5 ICONS="${icons:-}" # env seed; config `icons=ascii` overrides below # Session awareness (see scripts/sessions.sh). Enrichment only: when the session # store is unreadable — a terminal-only machine, no jq, a non-Desktop host — # every check below degrades to "no info" and landing behaves exactly as it did # before this existed. It must never become a hard dependency. SESSION_CHECK="on" SESSION_LIVE_SECS=600 # Whether `fleet status` surfaces the prunable-worktree backlog. On by default: # the whole point is that an unswept backlog is invisible otherwise. PRUNE_HINT="on" # === ONE-RUN ENV OVERRIDES ==================================================== # FLEET_SKIP_SESSION_CHECK means "skip the live-owner gate for THIS invocation" # — but an exported env var inherits into every child process, test_cmd # included. On 2026-09-01 `FLEET_SKIP_SESSION_CHECK=1 fleet land` leaked the # override into the post-merge test suite, which runs fleet-ops' own self-test; # the live-owner gate that suite asserts REFUSES was disarmed inside every # sandboxed test repo, 6 tests failed, and a genuinely green merge was # hard-reset as a false FAIL. So: read it ONCE here, strip it from the # environment immediately, and use the internal copy everywhere below. land_one # additionally sanitizes the wider FLEET_* knob family around test_cmd. SKIP_SESSION_CHECK="${FLEET_SKIP_SESSION_CHECK:-}" unset FLEET_SKIP_SESSION_CHECK # === CONFIG =================================================================== # The config is PARSED, never `source`d. Two reasons, both learned the hard way # (2026-07: every documented key had been a silent no-op since the file shipped): # # 1. `source` binds the key's own case — the file documents lowercase keys # (`test_cmd=`), the script reads UPPERCASE (`$TEST_CMD`), so a sourced # config set a variable nothing ever read. `fleet land` therefore never ran # a test gate on any repo; it always fell through to signal.sh's log gate. # 2. `source` is bash, so an unquoted value containing spaces # (`test_cmd=python -m pytest`) is not an assignment at all — bash reads it # as "run `-m` with test_cmd exported". It fails, and the old `2>/dev/null` # swallowed the error, making a broken config indistinguishable from none. # # Parsing also means the config can't execute code, which a `source`d file could. # Keys are matched case-insensitively so configs written either way keep working. # NEVER silence this parser: a config that yields nothing must say so. config_warn() { local msg="[$(date '+%H:%M:%S')] fleet config: $*" echo "$msg" >&2 # activity.log may not exist yet (ensure_fleet_dir runs later) — best effort. [[ -d "$FLEET_DIR" ]] && echo "$msg" >> "$LOG" 2>/dev/null return 0 } # Strip leading and trailing whitespace. bash 3.2-safe (macOS ships 3.2). config_trim() { local s=$1 s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } # Parse key=value lines. Grammar (documented identically in SKILL.md): # - one key=value per line; whitespace around key and '=' is ignored # - value runs to end of line, so spaces need NO quoting # - optional surrounding "…" or '…' is stripped (protects a literal trailing #) # - unquoted values lose a trailing ` # comment`; quoted values keep everything # - '#' at line start = comment; blank lines ignored load_config() { local file=$1 [[ -f "$file" ]] || return 0 if [[ ! -r "$file" ]]; then config_warn "$file exists but is not readable — using defaults" return 0 fi local recognised=0 lineno=0 line key val while IFS= read -r line || [[ -n "$line" ]]; do lineno=$((lineno + 1)) line="${line%$'\r'}" # CRLF configs (Windows editors) line="$(config_trim "$line")" [[ -z "$line" || "$line" == \#* ]] && continue if [[ "$line" != *=* ]]; then config_warn "$file:$lineno — not a key=value line, ignored: $line" continue fi key="$(config_trim "${line%%=*}")" key="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" val="$(config_trim "${line#*=}")" case "$val" in # Quoted: value is everything up to the closing quote; rest is discarded. \"*) val="${val#\"}"; val="${val%%\"*}" ;; \'*) val="${val#\'}"; val="${val%%\'*}" ;; # Unquoted: drop a trailing ` # comment` (matches shell intuition, and the # SKILL.md example block is annotated that way — a verbatim copy must work). *) val="${val%%[[:space:]]#*}"; val="$(config_trim "$val")" ;; esac case "$key" in mode) MODE="$val" ;; worktree_root) WORKTREE_ROOT="$val" ;; test_cmd) TEST_CMD="$val" ;; forbidden_pattern) FORBIDDEN_PATTERN="$val" ;; base_branch) BASE_BRANCH="$val" ;; icons) ICONS="$val" ;; session_check) SESSION_CHECK="$val" ;; prune_hint) PRUNE_HINT="$val" ;; session_live_secs) if [[ "$val" =~ ^[0-9]+$ ]]; then SESSION_LIVE_SECS="$val" else config_warn "$file:$lineno — session_live_secs must be an integer, got '$val' (keeping $SESSION_LIVE_SECS)" continue fi ;; poll_interval) if [[ "$val" =~ ^[0-9]+$ ]]; then POLL_INTERVAL="$val" else config_warn "$file:$lineno — poll_interval must be an integer, got '$val' (keeping $POLL_INTERVAL)" continue fi ;; *) config_warn "$file:$lineno — unrecognised key '$key' (ignored)" continue ;; esac recognised=$((recognised + 1)) done < "$file" if [[ $recognised -eq 0 ]]; then config_warn "$file set no recognised keys — running on defaults (test gate OFF)" fi return 0 } load_config "$CONFIG" # === END CONFIG =============================================================== # Shared terminal-output helpers (see docs/TERMINAL-DESIGN.md). # Sourced AFTER the config so `icons=ascii` in the config can reach term_init — # when this ran first, that documented key was read before it was ever set. # shellcheck source=../../_lib/term.sh . "$SCRIPT_DIR/../../_lib/term.sh" # Honor legacy FLEET_ASCII alongside TERM_ASCII. if [[ "${FLEET_ASCII:-}" == "1" || "$ICONS" == "ascii" ]]; then export TERM_ASCII=1; fi term_init # Icons resolved through the shared term lib (term_state_icon). ICON_RUNNING="$(term_state_icon RUNNING)" ICON_READY="$(term_state_icon READY)" ICON_LANDED="$(term_state_icon LANDED)" ICON_FAILED="$(term_state_icon FAILED)" ICON_CONFLICT="$(term_state_icon CONFLICT)" ICON_UNKNOWN="?" # Cross-platform mtime: GNU stat (Linux/Git Bash) vs BSD stat (macOS) file_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || date +%s } # Lane files are named after branches, but branch names can contain '/' # (feat/x, fleet/x) — which would nest the lane into a nonexistent subdir and # break `track`/status/daemon. Encode '/' (and the escape char) so every lane is # one flat file under lanes/, and decode when mapping a filename back to a branch. # signal.sh carries an identical encoder so the two interoperate. encode_lane() { local s=${1//\%/%25}; printf '%s' "${s//\//%2F}"; } decode_lane() { local s=${1//%2F/\/}; printf '%s' "${s//%25/\%}"; } log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG" >&2; } maybe_commit_gitignore() { # Auto-commit the .gitignore append from ensure_fleet_dir, but only when # safe: must be on BASE_BRANCH and .gitignore must be the only change in # the tree. Otherwise warn loudly — the daemon's land step will refuse # otherwise with "main has uncommitted tracked changes". local current current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") if [[ "$current" != "$BASE_BRANCH" ]]; then log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths." log " You're on '$current', not '$BASE_BRANCH'. Switch to" log " '$BASE_BRANCH' and commit .gitignore before 'fleet start'," log " or the daemon will refuse to land with" log " 'uncommitted tracked changes — clean before landing'." return 0 fi local other_changes other_changes=$(git status --porcelain 2>/dev/null | grep -vE '^.. \.gitignore$' || true) if [[ -n "$other_changes" ]]; then log "ACTION REQUIRED: .gitignore updated for fleet-ops runtime paths," log " but other uncommitted changes exist on $BASE_BRANCH." log " Commit .gitignore yourself before 'fleet start' or" log " the daemon will refuse to land. Suggested:" log " git add .gitignore && git commit -m 'chore: gitignore fleet-ops runtime state'" return 0 fi git add .gitignore 2>/dev/null || { log "WARN: git add .gitignore failed"; return 0; } if git commit -m "chore: gitignore fleet-ops runtime state" -- .gitignore >/dev/null 2>&1; then log "auto-committed .gitignore (fleet-ops runtime paths: .claude/fleet/, .fleet-worktrees/)" else log "WARN: auto-commit of .gitignore failed — commit it manually before 'fleet start'" fi } ensure_fleet_dir() { mkdir -p "$LANES_DIR" [[ -f "$FLEET_DIR/signal.sh" ]] || cp "$SCRIPT_DIR/signal.sh" "$FLEET_DIR/signal.sh" chmod +x "$FLEET_DIR/signal.sh" 2>/dev/null || true # sessions.sh ships alongside signal.sh so a lane session — which only ever # sees .claude/fleet/, never the installed skill dir — can resolve MAIN's # address when it signals READY. Refreshed every time so a skill update # propagates (signal.sh is deliberately NOT overwritten: a repo may have # customised it). cp -f "$SCRIPT_DIR/sessions.sh" "$FLEET_DIR/sessions.sh" 2>/dev/null || true chmod +x "$FLEET_DIR/sessions.sh" 2>/dev/null || true # Auto-ignore fleet-ops runtime state in git so it doesn't show as "dirty" # or get committed. Two paths: # .claude/fleet/ — lanes/, daemon.pid, activity.log, signal.sh, config # .fleet-worktrees/ — default worktree root (top-level so headless # Claude lane sessions can write there) if git rev-parse --git-dir >/dev/null 2>&1; then [[ -f .gitignore ]] || touch .gitignore # Accept the `dir/*` form as already-ignoring, not just `dir/`. A repo that # wants to track ONE file in here (e.g. a `config.example` so the landing # gate survives a fresh clone) MUST write `.claude/fleet/*` plus a `!` # negation — git cannot re-include a file whose parent DIRECTORY is # excluded. An exact-match grep does not see that as ignored, so it appended # a bare `.claude/fleet/` and auto-committed it, re-excluding the directory # and quietly undoing the repo's intent. local appended=0 if ! grep -qxE '\.claude/fleet/\*?' .gitignore 2>/dev/null; then echo '.claude/fleet/' >> .gitignore appended=1 fi if ! grep -qxE '\.fleet-worktrees/\*?' .gitignore 2>/dev/null; then echo '.fleet-worktrees/' >> .gitignore appended=1 fi # NB: plain `[[ ... ]] && cmd` here would return 1 when nothing was # appended, and under set -e that kills any caller invoked after init. if [[ $appended -eq 1 ]]; then maybe_commit_gitignore fi fi } is_dirty_tracked() { # True only if tracked files have uncommitted changes (ignores untracked files) ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null } lane_state() { local f="$LANES_DIR/$(encode_lane "$1")"; [[ -f "$f" ]] && head -n1 "$f" || echo "MISSING"; } set_lane_state() { local l=$1 s=$2 f f="$LANES_DIR/$(encode_lane "$l")" shift 2 if [[ $# -gt 0 ]]; then printf '%s\n%s\n' "$s" "$*" > "$f" else printf '%s\n' "$s" > "$f" fi } scrub_diff() { # echoes hits (one per line) for given branch's diff vs base. Empty = clean. # ADDED lines only ('+…', not the '+++' file header): deletion lines, context # lines, and @@ hunk-header function-context must not trip the gate — removing # a forbidden marker is a fix, and a marker merely NEAR an edit is not one # (both false-positived here, 2026-07). local branch=$1 git diff "$BASE_BRANCH"..."$branch" 2>/dev/null | grep -E '^\+' | grep -vE '^\+\+\+ ' | grep -nE "$FORBIDDEN_PATTERN" || true } refuse_if_shared_tree() { local trees lane_count trees=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}' | sort -u | wc -l) lane_count=$(ls -1 "$LANES_DIR" 2>/dev/null | wc -l) if [[ "$lane_count" -gt 1 && "$trees" -le 1 && "$MODE" != "branch" ]]; then log "ERROR: $lane_count lanes but only $trees worktree — sessions will collide" log " Use worktrees, separate clones, or set mode=branch in $CONFIG to override" return 1 fi } # The landing gate must be ARMED, or absent LOUDLY — never silently absent. # # TEST_CMD comes from $CONFIG, which repos routinely gitignore along with the # rest of .claude/fleet/ (lane state is machine-local). So a `git clean`, a # fresh clone, or a new worktree leaves it EMPTY. Until 2026-08-04 that case # fell through to signal.sh's log gate — which verifies nothing at all when a # lane signalled READY without a test log. The branch merged to $BASE_BRANCH # having run zero tests, and the only trace was one line in activity.log. # # That is the dangerous shape: not "landing fails" but "landing SUCCEEDS having # tested nothing". A gate that degrades to no gate is worse than one that # breaks, because nothing reports the loss. Refuse instead. # # Deliberately does NOT touch lane state: an unarmed gate is a repo-level fault, # not the lane's, and marking every lane CONFLICT would leave a human to undo # state that was never wrong. Callers refuse BEFORE mutating anything. require_test_cmd() { [[ -n "$TEST_CMD" ]] && return 0 log "REFUSE: no test_cmd resolved from $CONFIG — the landing gate is UNARMED" log " Landing now would merge without running any tests." log " Set test_cmd in $CONFIG (some repos ship $CONFIG.example — copy it)," log " then confirm with: fleet config" return 1 } cmd_init() { ensure_fleet_dir [[ $# -eq 0 ]] && { echo "usage: fleet init <name>..." >&2; exit 1; } local mode="$MODE" [[ "$mode" == "auto" ]] && mode="worktree" # default: worktree if git allows it for name in "$@"; do if git rev-parse --verify "$name" >/dev/null 2>&1; then log "skip branch (exists): $name" else git branch "$name" "$BASE_BRANCH" log "created branch: $name" fi if [[ "$mode" == "worktree" ]]; then local wt="$WORKTREE_ROOT/$name" if [[ -d "$wt" ]]; then log "skip worktree (exists): $wt" else mkdir -p "$WORKTREE_ROOT" git worktree add "$wt" "$name" log "created worktree: $wt" fi fi set_lane_state "$name" "RUNNING" done echo "" echo "Fleet initialized. Hand each session the prompt template:" echo " $SCRIPT_DIR/../references/session-prompt.md" echo "Then: bash $0 start" } cmd_track() { # Register existing branches as lanes — the bridge from natively-spawned # work (agent teams, claude --bg auto-worktrees) into the landing queue. # Never creates or touches worktrees; the branch is taken as-is. ensure_fleet_dir [[ $# -eq 0 ]] && { echo "usage: fleet track <branch>..." >&2; exit 1; } local rc=0 for name in "$@"; do if ! git rev-parse --verify "refs/heads/$name" >/dev/null 2>&1; then log "ERROR: no local branch '$name' — nothing to track" rc=1 continue fi if [[ -f "$LANES_DIR/$(encode_lane "$name")" ]]; then log "already tracked: $name ($(lane_state "$name"))" else set_lane_state "$name" "RUNNING" log "tracking lane: $name" fi done return $rc } format_age() { local secs=$1 if [[ $secs -lt 60 ]]; then printf '%ds' "$secs" elif [[ $secs -lt 3600 ]]; then printf '%dm' "$((secs/60))" else printf '%dh%dm' "$((secs/3600))" "$(( (secs%3600)/60 ))" fi } icon_for_state() { case "$1" in RUNNING) echo "$ICON_RUNNING" ;; READY) echo "$ICON_READY" ;; LANDED) echo "$ICON_LANDED" ;; FAILED) echo "$ICON_FAILED" ;; CONFLICT) echo "$ICON_CONFLICT" ;; *) echo "$ICON_UNKNOWN" ;; esac } # Bucket lanes by state into parallel arrays. Sets: # total, active — globals # state_buckets[0..4] — newline-joined "branch|age|meta" # state_counts[0..4] — count per state # Order: 0=RUNNING 1=READY 2=CONFLICT 3=FAILED 4=LANDED __fleet_bucket() { total=0; active=0 state_buckets=("" "" "" "" "") state_counts=(0 0 0 0 0) local now=$(date +%s) for f in "$LANES_DIR"/*; do [[ -f "$f" ]] || continue total=$((total+1)) local branch state meta mtime secs age idx branch=$(decode_lane "$(basename "$f")") state=$(head -n1 "$f") meta=$(sed -n '2p' "$f") mtime=$(file_mtime "$f") secs=$((now - mtime)) age=$(format_age "$secs") [[ "$state" != "LANDED" && "$state" != "FAILED" ]] && active=$((active+1)) idx=-1 case "$state" in RUNNING) idx=0 ;; READY) idx=1 ;; CONFLICT) idx=2 ;; FAILED) idx=3 ;; LANDED) idx=4 ;; esac [[ $idx -lt 0 ]] && continue state_counts[$idx]=$(( state_counts[idx] + 1 )) state_buckets[$idx]="${state_buckets[$idx]}${branch}|${age}|${meta}"$'\n' done } # Daemon health → "healthy" or "busted" __fleet_daemon_state() { if [[ -f "$PID_FILE" ]]; then local pid pid=$(cat "$PID_FILE" 2>/dev/null || echo "") if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then printf 'healthy' return fi fi printf 'busted' } # Footer composition shared by all panel views. __fleet_footer() { local active=$1 daemon_state=$2 local hotkeys # Separators come from term.sh ($TERM_DOT), never an authored U+00B7. A literal # middle dot bypasses the ASCII-fallback registry, so it survives TERM_ASCII=1 # and mojibakes on non-UTF-8 consoles. tests/check-resources.sh gates this. hotkeys="$(term_hotkey R refresh) ${TERM_DOT} $(term_hotkey L land) ${TERM_DOT} $(term_hotkey '?' help)" local healths healths="$(term_health "$daemon_state" "daemon")" [[ "$active" -gt 0 ]] && healths="$healths $(term_health pending "$active active")" term_panel_close "$hotkeys" "$healths" } # Default panel view — design-system grouped tree fleet_view_panel() { ensure_fleet_dir local order=(RUNNING READY CONFLICT FAILED LANDED) local total active local state_buckets state_counts __fleet_bucket load_session_index local daemon_state daemon_state=$(__fleet_daemon_state) echo "" term_panel_open fleet fleet "$TERM_GLYPH_BRANCH $BASE_BRANCH" if [[ $total -eq 0 ]]; then term_panel_vert term_panel_vert printf '%s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "no lanes yet" term_panel_vert term_panel_vert printf '%s %s %s\n' "$(term_color dim "$TERM_TREE_VERT")" "$TERM_GLYPH_TIP" "to get started:" term_panel_vert printf '%s 1. fleet init <name>...\n' "$(term_color dim "$TERM_TREE_VERT")" printf '%s 2. (work in each lane)\n' "$(term_color dim "$TERM_TREE_VERT")" printf '%s 3. fleet start\n' "$(term_color dim "$TERM_TREE_VERT")" term_panel_vert term_panel_vert term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")" echo "" return fi term_panel_vert term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) ${TERM_DOT} $active active" term_panel_vert local i for i in 0 1 2 3 4; do local n=${state_counts[$i]} [[ $n -eq 0 ]] && continue local state=${order[$i]} term_section "$state" "$state" "$n" local lines="${state_buckets[$i]}" local c_idx=0 c_last=$((n - 1)) local branch age meta while IFS='|' read -r branch age meta; do [[ -z "$branch" ]] && continue local c_conn if [[ $c_idx -eq $c_last ]]; then c_conn="$TERM_TREE_LAST"; else c_conn="$TERM_TREE_BRANCH"; fi # Build the rail glyph from this lane's commits-ahead and state. local ahead head_kind rail ahead=$(git rev-list --count "${BASE_BRANCH}..${branch}" 2>/dev/null || echo 0) head_kind="HEAD" [[ "$state" == "CONFLICT" || "$state" == "FAILED" ]] && head_kind="CONFLICT" rail=$(term_rail "$ahead" "$head_kind") local own; own=$(owner_annotation "$branch") local shown_meta="${meta:-}" if [[ -n "$own" ]]; then # ASCII separator on purpose — this row must survive TERM_ASCII=1. [[ -n "$shown_meta" ]] && shown_meta="$shown_meta - $own" || shown_meta="$own" fi term_leaf_line "$c_conn" "$branch" "$rail" "$shown_meta" "$age" c_idx=$((c_idx+1)) done <<< "$lines" term_panel_vert done prune_status_hint __fleet_footer "$active" "$daemon_state" echo "" } # Verbose view — per-lane detail blocks rendered in panel grammar. # Each lane gets a header row + sub-rows for worktree, commits, and note. fleet_view_verbose() { ensure_fleet_dir local total active local state_buckets state_counts __fleet_bucket load_session_index local daemon_state daemon_state=$(__fleet_daemon_state) local now=$(date +%s) echo "" term_panel_open fleet "fleet ${TERM_DOT} verbose" "$TERM_GLYPH_BRANCH $BASE_BRANCH" if [[ $total -eq 0 ]]; then term_panel_vert printf '%s no lanes yet\n' "$(term_color dim "$TERM_TREE_VERT")" term_panel_vert term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "v2.4.9")" echo "" return fi term_panel_vert term_summary_line "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) ${TERM_DOT} $active active" term_panel_vert for f in "$LANES_DIR"/*; do [[ -f "$f" ]] || continue local branch state meta mtime age secs wt commits color label_state branch=$(decode_lane "$(basename "$f")") state=$(head -n1 "$f") meta=$(sed -n '2p' "$f") mtime=$(file_mtime "$f") secs=$((now - mtime)) age=$(format_age "$secs") wt=$(worktree_path_for "$branch" 2>/dev/null || echo "") commits=$(git rev-list --count "$BASE_BRANCH..$branch" 2>/dev/null || echo "?") color="" case "$state" in RUNNING|PENDING|CONFLICT|WARN) color="yellow" ;; READY|LANDED|DONE|OK) color="green" ;; FAILED|ERROR) color="red" ;; esac label_state="$state" [[ -n "$color" ]] && label_state=$(term_color "$color" "$state") # Lane header row printf '%s%s %-30s %-10s %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_BRANCH$TERM_PANEL_HRULE")" \ "$branch" \ "$label_state" \ "$(term_color dim "$age")" # Detail sub-rows (under the lane's │ continuation) if [[ -n "$wt" ]]; then local wt_short="$wt" repo_root="${REPO_ROOT:-}" [[ -n "$repo_root" ]] && wt_short="${wt#$repo_root/}" if [[ "$wt_short" == "$wt" && -n "$repo_root" ]]; then local repo_native repo_native=$(cygpath -m "$repo_root" 2>/dev/null || echo "$repo_root") wt_short="${wt#$repo_native/}" fi printf '%s %s worktree: %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$wt_short")" fi if [[ "$commits" != "?" && "$commits" != "0" ]]; then printf '%s %s commits: %s ahead of %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$commits")" \ "$(term_color dim "$BASE_BRANCH")" fi if [[ -n "$meta" ]]; then printf '%s %s note: %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$meta")" fi local own_v; own_v=$(owner_annotation "$branch") if [[ -n "$own_v" ]]; then printf '%s %s owner: %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_VERT")" \ "$own_v" fi term_panel_vert done prune_status_hint __fleet_footer "$active" "$daemon_state" echo "" } cmd_fleet() { local mode="panel" while [[ $# -gt 0 ]]; do case "$1" in -v|--verbose) mode="verbose"; shift ;; -g|--grouped) mode="panel"; shift ;; *) shift ;; esac done case "$mode" in verbose) fleet_view_verbose ;; *) fleet_view_panel ;; esac } # MAIN = the one session per repo that coordinates: it lands, deploys, and # triages. Everyone else is a lane. This is not a new idea — worktree-boundaries # doctrine already says the base checkout is the integration tree and must not # host a writing session — `fleet main` just makes the role addressable, so a # lane can say "I'm ready, come land me" instead of writing a file and hoping. # # Resolution is by cwd (the session sitting in the repo root IS the coordinator), # with an explicit pin in .claude/fleet/main to override when the heuristic is # wrong or several sessions share the root. cmd_main() { local sub=${1:-show} local pin="$FLEET_DIR/main" case "$sub" in show|"") local row; row=$(main_session_row) if [[ -z "$row" ]]; then echo "no MAIN session resolved for this repo" >&2 if ! session_enabled; then echo " (session awareness is off or sessions.sh is missing)" >&2 else echo " no session's cwd matches $REPO_ROOT — open one there, or pin with:" >&2 echo " fleet main claim <sessionId>" >&2 fi return 3 fi # stdout is data: sessionId first so `fleet main show | cut -f1` addresses it printf '%s\t%s\t%s\t%s\n' \ "$(sfield "$row" 2)" "$(sfield "$row" 3)" \ "$([[ "$(sfield "$row" 7)" == "1" ]] && echo live || echo idle)" \ "$(sfield "$row" 5)" [[ -f "$pin" ]] && echo "(pinned via $pin)" >&2 return 0 ;; claim) ensure_fleet_dir local id=${2:-} if [[ -z "$id" ]]; then local row; row=$(main_session_row) id=$(sfield "$row" 2) [[ -z "$id" ]] && { echo "fleet main claim: could not auto-resolve a session; pass a sessionId" >&2; return 3; } fi printf '# MAIN coordinator session for this repo (fleet main release to clear)\n%s\n' "$id" > "$pin" echo "MAIN pinned: $id" >&2 printf '%s\n' "$id" ;; release) if [[ -f "$pin" ]]; then rm -f "$pin"; echo "MAIN pin cleared" >&2 else echo "no MAIN pin to clear" >&2; fi ;; *) echo "usage: fleet main [show|claim [<sessionId>]|release]" >&2; return 2 ;; esac } cmd_config() { # Print the RESOLVED config — the observability that was missing while every # documented key was a silent no-op. stdout is data only (key=value, parseable); # advice and warnings go to stderr. if [[ -f "$CONFIG" ]]; then echo "# source: $CONFIG" >&2 else echo "# source: none ($CONFIG absent) — all defaults" >&2 fi echo "mode=$MODE" echo "worktree_root=$WORKTREE_ROOT" echo "test_cmd=$TEST_CMD" echo "forbidden_pattern=$FORBIDDEN_PATTERN" echo "base_branch=$BASE_BRANCH" echo "poll_interval=$POLL_INTERVAL" echo "icons=$ICONS" echo "session_check=$SESSION_CHECK" echo "session_live_secs=$SESSION_LIVE_SECS" echo "prune_hint=$PRUNE_HINT" if [[ -z "$TEST_CMD" ]]; then echo "WARNING: no test_cmd — 'fleet land' will not run a test gate" >&2 fi # Same observability lesson as test_cmd: say plainly whether the gate is armed, # rather than letting an unavailable store look like a passing check. if session_enabled; then if [[ -n "$(main_session_row)" ]]; then echo "# session awareness: ON (store readable)" >&2 # Whether THIS session can be recognised decides if it can land its own # lane unaided; unresolvable self is a silent fallback to refusing, so # state it rather than letting it look like a gate misfire. if [[ -n "$(bash "$SESSIONS_SH" self 2>/dev/null)" ]]; then echo "# self-identity: resolved — this session can land lanes it owns" >&2 else echo "# self-identity: UNRESOLVED — landing a lane this session owns will refuse" >&2 fi else echo "# session awareness: ON but no sessions resolved — store missing, jq missing, or terminal-only host" >&2 fi else echo "# session awareness: OFF — 'fleet land' will not check for live lane owners" >&2 fi return 0 } cmd_scrub_check() { local branch=${1:-} [[ -z "$branch" ]] && { echo "usage: fleet scrub-check <branch>" >&2; exit 1; } local hits hits=$(scrub_diff "$branch") if [[ -n "$hits" ]]; then echo "FORBIDDEN PATTERNS in $branch:" echo "$hits" | head -20 return 1 fi echo "OK: $branch (no forbidden patterns)" } # === SESSION AWARENESS ======================================================== # Answers "who owns this lane, and are they still writing?" by reading the # Claude Desktop session store off disk (scripts/sessions.sh explains why disk # and not the ccd_session_mgmt MCP tools — those exist only inside Desktop and # cannot be called from a script at all). # # EVERY function here is best-effort. sessions.sh exits 3 when the store or jq # is missing, and fleet.sh runs under `set -e`, so each call MUST be guarded # with `|| true`. An unguarded call would turn "this machine has no Desktop # store" into "fleet land crashes". SESSIONS_SH="$SCRIPT_DIR/sessions.sh" session_enabled() { [[ "$(printf '%s' "$SESSION_CHECK" | tr '[:upper:]' '[:lower:]')" != "off" ]] \ && [[ -f "$SESSIONS_SH" ]] } # TSV row for the session owning $1, or empty. $2=--fresh forces an # authoritative liveness read (used by the land gate). lane_owner() { session_enabled || return 0 local branch=$1 fresh=${2:-} FLEET_SESSION_LIVE_SECS="$SESSION_LIVE_SECS" \ bash "$SESSIONS_SH" owner $fresh "$branch" 2>/dev/null || true } # TSV row for this repo's MAIN/coordinator session, or empty. main_session_row() { session_enabled || return 0 FLEET_SESSION_LIVE_SECS="$SESSION_LIVE_SECS" \ bash "$SESSIONS_SH" main 2>/dev/null || true } # Column accessors: 1=branch 2=sessionId 3=title 4=lastActivityMs 5=cwd # 6=archived 7=live sfield() { printf '%s' "$1" | cut -f"$2"; } # Status views resolve an owner per lane. Doing that with one sessions.sh call # each would re-pay process spawn N times, so the whole index is pulled once per # fleet.sh invocation and queried in-memory. SESSION_INDEX_CACHE="" SESSION_INDEX_LOADED=0 # 1 only when sessions.sh returned 0 — i.e. the store was actually READ. # The distinction matters to `fleet prune`: "the store says no session owns this # branch" is evidence of abandonment, while "the store could not be read" is no # evidence at all, and the two are indistinguishable from an empty index alone. # Anything that can't tell them apart must not classify a worktree removable. SESSION_STORE_OK=0 load_session_index() { session_enabled || return 0 [[ $SESSION_INDEX_LOADED -eq 1 ]] && return 0 SESSION_INDEX_LOADED=1 local rc=0 SESSION_INDEX_CACHE=$(FLEET_SESSION_LIVE_SECS="$SESSION_LIVE_SECS" \ bash "$SESSIONS_SH" index 2>/dev/null) || rc=$? [[ $rc -eq 0 ]] && SESSION_STORE_OK=1 return 0 } # Full TSV row of the newest session owning branch $1, from the in-memory index. # Same tie-break as sessions.sh's own `owner`: non-archived outranks archived # (col 6 asc), then newest activity (col 4 desc) — a branch reused after its # original session was archived belongs to whoever is using it now. owner_row_cached() { [[ -z "$SESSION_INDEX_CACHE" ]] && return 0 printf '%s\n' "$SESSION_INDEX_CACHE" \ | awk -F'\t' -v w="$1" '$1 == w' \ | sort -t"$(printf '\t')" -k6,6n -k4,4nr \ | head -n1 } # "title<TAB>live" for the newest session owning $1, or empty. owner_brief() { [[ -z "$SESSION_INDEX_CACHE" ]] && return 0 printf '%s\n' "$SESSION_INDEX_CACHE" \ | awk -F'\t' -v w="$1" '$1 == w { print $4"\t"$3"\t"$7 }' \ | sort -k1,1nr | head -n1 | cut -f2,3 } # One-line owner annotation for a lane row: "· owned by 'X' (live)" or empty. owner_annotation() { local b=$1 brief title live brief=$(owner_brief "$b") [[ -z "$brief" ]] && return 0 title=$(printf '%s' "$brief" | cut -f1) live=$(printf '%s' "$brief" | cut -f2) [[ ${#title} -gt 28 ]] && title="${title:0:25}..." # Deliberately ASCII: this string lands inside panel rows that must survive # FLEET_ASCII=1 and non-UTF-8 Windows consoles (SKILL.md "Compatibility"). if [[ "$live" == "1" ]]; then printf '%s' "$(term_color yellow "[live]") $title" else printf '%s' "$(term_color dim "[idle]") $title" fi } # Is session id $1 the session running THIS script? Empty/unresolvable self is # always false — an unknown identity must never satisfy an exemption. SELF_SESSION_ID="" SELF_SESSION_LOADED=0 session_is_self() { session_enabled || return 1 if [[ $SELF_SESSION_LOADED -eq 0 ]]; then SELF_SESSION_LOADED=1 SELF_SESSION_ID=$(bash "$SESSIONS_SH" self 2>/dev/null) || SELF_SESSION_ID="" fi [[ -n "$SELF_SESSION_ID" && "$1" == "$SELF_SESSION_ID" ]] } # Every OTHER live session that also owns branch $1 (excluding session $2), as # "id<TAB>title" rows. Liveness is re-read per candidate rather than taken from # the cached index — same standard as `owner --fresh`, because this decides a # refusal, and the index cache has a 15-minute TTL. peer_live_owners() { local branch=$1 self=$2 row id load_session_index [[ -z "$SESSION_INDEX_CACHE" ]] && return 0 while IFS= read -r row; do [[ -z "$row" ]] && continue id=$(sfield "$row" 2) [[ "$id" == "$self" ]] && continue [[ "$(bash "$SESSIONS_SH" live "$id" 2>/dev/null)" == "1" ]] || continue printf '%s\t%s\n' "$id" "$(sfield "$row" 3)" done < <(printf '%s\n' "$SESSION_INDEX_CACHE" | awk -F'\t' -v w="$branch" '$1 == w') return 0 } # The gate itself. Refuses to land a lane whose owning session is still live — # landing under a session that is mid-turn means merging a branch it may still # be committing to, and then rebasing its worktree out from under it. # # SELF-OWNERSHIP IS EXEMPT, and the reason is the whole design: that hazard is # about a CONCURRENT writer. A session landing its own lane is not one — it is # blocked inside this very call, so it is provably not mid-commit, and # "rebasing its worktree out from under it" describes the tree it is # deliberately retiring. Before this exemption, a lane session that finished # its work could only land it with a blanket override, which disarms the gate # for the peers it genuinely protects. A narrow exemption beats a blunt one. # # It stays conservative in both directions: unresolvable self never matches, # and self must be the ONLY live owner. A second live session writing the same # branch is the real hazard, and refuses exactly as before. # Returns 0 = safe to land, 1 = refuse. session_land_gate() { local branch=$1 session_enabled || return 0 local row; row=$(lane_owner "$branch" --fresh) [[ -z "$row" ]] && return 0 # unknown owner → no opinion → allow local live; live=$(sfield "$row" 7) [[ "$live" != "1" ]] && return 0 # idle → allow local title; title=$(sfield "$row" 3) local id; id=$(sfield "$row" 2) if session_is_self "$id"; then local peers pid ptitle peers=$(peer_live_owners "$branch" "$id") if [[ -z "$peers" ]]; then log "landing own lane: $branch is owned by THIS session ($id) — not a concurrent writer" return 0 fi # Self plus someone else: the someone else is the hazard, so say who. log "REFUSE LAND: $branch is owned by this session AND another LIVE session:" while IFS=$'\t' read -r pid ptitle; do [[ -n "$pid" ]] && log " '$ptitle' ($pid)" done <<< "$peers" log " a peer may still be committing to it — coordinate before landing." return 1 fi log "REFUSE LAND: $branch is owned by a LIVE session — '$title' ($id)" log " that session was active within ${SESSION_LIVE_SECS}s and may still be committing." log " wait for it to finish, or override with: session_check=off (or FLEET_SKIP_SESSION_CHECK=1)" return 1 } # === END SESSION AWARENESS ==================================================== # === PRUNE ==================================================================== # Worktree housekeeping. This is the ONLY part of fleet-ops that deletes # anything, so read rules/worktree-boundaries.md before changing a line of it. # # THE HAZARD, stated plainly: a worktree that looks orphaned frequently is not. # `.claude/worktrees/<slug>` names are machine-generated and say nothing about # whether anyone is using them, and a session that looks idle may simply be # between turns. Removing a worktree destroys its UNCOMMITTED and UNTRACKED # files permanently — git has never seen those bytes and cannot give them back. # COMMITTED lane work is different: it lives in the shared object store and # survives the directory, recoverable with # git worktree add <path> <branch> # Separating "committed and already in base" from everything else is therefore # the classifier's entire job, and every ambiguous case resolves away from # deletion. # # CLASSIFICATION — first match wins, and the ORDER is the safety argument: # # 1 primary / locked / the caller's own tree KEEP structurally untouchable # 1b git says the directory is gone REVIEW git's own bookkeeping — # `git worktree prune` # 2 owning session is LIVE KEEP someone is writing here # 3 session store unreadable, or awareness REVIEW no evidence of anything # switched off => nothing can be SAFE # 4 detached HEAD REVIEW no branch to attribute # 5 uncommitted or untracked changes REVIEW removal would destroy them # 6 commits not yet in <base> REVIEW unintegrated work # 7 merged + clean + owner archived or absent SAFE finished and recoverable # 8 anything else REVIEW default deny # # Rule 6 deliberately folds together two readings that cannot both apply to one # row — "unmerged commits => KEEP" and "unmerged with no live owner => REVIEW". # Neither is ever removed, so the choice is purely about which bucket the # operator is asked to look at, and an abandoned unmerged lane is exactly the # backlog this command exists to surface. KEEP is therefore reserved for one # meaning only: hands off, not yours to judge. PRUNE_ROWS="" # accumulated TSV: path \t branch \t bucket \t reason # Normalise a path for comparison: forward slashes, no trailing slash, # lowercased (Windows paths are case-insensitive and git's casing of the drive # letter does not always match the shell's). prune_norm() { local p=${1//\\//} p=${p%/} printf '%s' "$p" | tr '[:upper:]' '[:lower:]' } # Changed + untracked entry count. UNTRACKED counts on purpose: those are # precisely the files git cannot recover, and `git worktree remove` refuses a # tree containing them anyway. prune_dirty_count() { git -C "$1" status --porcelain 2>/dev/null | grep -c '.' || true } prune_row() { PRUNE_ROWS="${PRUNE_ROWS}$1"$'\t'"$2"$'\t'"$3"$'\t'"$4"$'\n' } # A shell path and a git path are not the same string on Windows: the shell says # /tmp/x or /c/Users/x, git says C:/Users/x. Comparing them raw NEVER matches, # which silently turns a guard into a no-op rather than into an error — the # failure mode that hid both the invoked-from guard and repo discovery until a # test caught them. Convert to git's mixed form before any such comparison. # (INVOKED_FROM does the same thing inline at the top of this file, because it # has to be captured before any function is defined.) prune_native() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1" 2>/dev/null || printf '%s' "$1" else printf '%s' "$1" fi } # Prune must work in a repo that has never run `fleet init`, so it cannot assume # .claude/fleet/ exists — and `log`'s `tee -a` into a missing directory fails, # which under `set -e` kills the whole command (it did: a store-unavailable # dry run exited 1 instead of reporting). Always to stderr; append to the # activity log only when there is one. Prune never creates that directory # itself: a command whose default is "change nothing" must not leave state. prune_log() { local msg="[$(date '+%H:%M:%S')] $*" echo "$msg" >&2 [[ -d "$FLEET_DIR" ]] && echo "$msg" >> "$LOG" 2>/dev/null return 0 } # Is this path one of Claude Code's own native session worktrees? Those are the # highest-risk rows: the directory name is meaningless, and the owning session # is often still open. They are never removable on a guess — only when the store # was readable AND it says the owner is archived or gone, which rules 3 and 7 # already require. The flag exists to mark them loudly in the table and to # trigger the re-verify before removal. prune_is_native() { case "$(prune_norm "$1")" in */.claude/worktrees/*) return 0 ;; *) return 1 ;; esac } # prune_emit <repo> <base> <path> <branch> <detached> <locked> <gone> <merged_list> prune_emit() { local repo=$1 base=$2 wt=$3 br=$4 det=$5 locked=$6 gone=$7 merged_list=$8 local wtn here wtn=$(prune_norm "$wt") here=$(prune_norm "$INVOKED_FROM") # 1 — structurally untouchable if [[ $locked -eq 1 ]]; then prune_row "$wt" "${br:-<detached>}" KEEP "locked by git"; return 0 fi if [[ "$wtn" == "$here" || "$here" == "$wtn"/* ]]; then prune_row "$wt" "${br:-<detached>}" KEEP "you are standing in it"; return 0 fi # git itself says the directory is gone. Nothing to lose and nothing to # classify — but this is git's own bookkeeping, so send it to git's own tool # rather than silently reporting a vanished tree as "clean". if [[ $gone -eq 1 ]]; then prune_row "$wt" "${br:-<detached>}" REVIEW "directory missing - run 'git worktree prune'"; return 0 fi # 2 — a live owner outranks every other consideration local orow="" olive="0" oarch="0" otitle="" if [[ -n "$br" && $SESSION_STORE_OK -eq 1 ]]; then orow=$(owner_row_cached "$br") if [[ -n "$orow" ]]; then olive=$(sfield "$orow" 7); oarch=$(sfield "$orow" 6); otitle=$(sfield "$orow" 3) fi fi if [[ "$olive" == "1" ]]; then prune_row "$wt" "$br" KEEP "live session: ${otitle:-?}"; return 0 fi # 3 — no session evidence at all. "The store says nobody owns this" is # evidence of abandonment; "the store could not be read" is not, and an # empty index looks identical to both. Degrade, never guess. if [[ $SESSION_STORE_OK -ne 1 ]]; then prune_row "$wt" "${br:-<detached>}" REVIEW "no session info - cannot prove abandoned"; return 0 fi # 4 — detached HEAD: no branch, so nothing to attribute an owner to if [[ $det -eq 1 || -z "$br" ]]; then prune_row "$wt" "<detached>" REVIEW "detached HEAD - no branch to attribute"; return 0 fi local is_merged=0 if printf '%s\n' "$merged_list" | grep -qxF -- "$br"; then is_merged=1; fi # 5 — uncommitted or untracked work: the only bytes git cannot give back local dirty dirty=$(prune_dirty_count "$wt") if [[ "${dirty:-0}" -gt 0 ]]; then local mstate="unmerged" [[ $is_merged -eq 1 ]] && mstate="merged" prune_row "$wt" "$br" REVIEW "$mstate but DIRTY - $dirty uncommitted/untracked"; return 0 fi # 6 — committed but not yet in base local ahead ahead=$(git -C "$repo" rev-list --count "$base..$br" 2>/dev/null || echo 0) if [[ $is_merged -ne 1 || "${ahead:-0}" != "0" ]]; then local who="no owner in store" if [[ -n "$orow" ]]; then if [[ "$oarch" == "1" ]]; then who="owner archived"; else who="owner idle"; fi fi prune_row "$wt" "$br" REVIEW "unmerged - ${ahead:-?} ahead of $base ($who)"; return 0 fi # 7 — merged, clean, and nobody is coming back for it if [[ -z "$orow" ]]; then prune_row "$wt" "$br" SAFE "merged + clean, no session owns it"; return 0 fi if [[ "$oarch" == "1" ]]; then prune_row "$wt" "$br" SAFE "merged + clean, owner archived"; return 0 fi # 8 — default deny. Merged and clean, but a non-archived session still owns # the branch: idle now, and free to wake up. Not ours to remove. prune_row "$wt" "$br" REVIEW "merged + clean, but owner still open: ${otitle:-?}" } # prune_classify <repo> <base> — fills PRUNE_ROWS. The PRIMARY worktree is # skipped entirely: it is the integration tree, not a lane, and listing it would # only add a row that can never be actioned. prune_classify() { local repo=$1 base=$2 PRUNE_ROWS="" load_session_index local merged_list merged_list=$(git -C "$repo" branch --merged "$base" 2>/dev/null \ | sed -e 's/^[*+ ]*//' -e 's/[[:space:]]*$//' || true) local raw raw=$(git -C "$repo" worktree list --porcelain 2>/dev/null || true) [[ -z "$raw" ]] && return 0 # Porcelain records are blank-line separated. Command substitution ate the # trailing newlines, so append one blank line to flush the final record. local line wt="" br="" det=0 locked=0 gone=0 first=1 while IFS= read -r line; do case "$line" in worktree\ *) wt=${line#worktree }; br=""; det=0; locked=0; gone=0 ;; branch\ *) br=${line#branch }; br=${br#refs/heads/} ;; detached) det=1 ;; locked*) locked=1 ;; prunable*) gone=1 ;; bare) locked=1 ;; "") if [[ -n "$wt" ]]; then [[ $first -eq 1 ]] || prune_emit "$repo" "$base" "$wt" "$br" "$det" "$locked" "$gone" "$merged_list" first=0 fi wt="" ;; esac done <<< "$raw"$'\n' return 0 } prune_count() { [[ -z "$PRUNE_ROWS" ]] && { printf '0'; return 0; } printf '%s' "$PRUNE_ROWS" | awk -F'\t' -v b="$1" 'NF && $3==b {n++} END{print n+0}' } # The base branch to classify against in an arbitrary repo. This repo's # configured base is meaningless next door, so resolve per-repo. prune_base_for() { local r=$1 b for b in "$BASE_BRANCH" main master; do git -C "$r" rev-parse --verify --quiet "refs/heads/$b" >/dev/null 2>&1 && { printf '%s' "$b"; return 0; } done git -C "$r" rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'main' } prune_render() { local total safe review keep safe=$(prune_count SAFE); review=$(prune_count REVIEW); keep=$(prune_count KEEP) total=$((safe + review + keep)) echo "" term_panel_open fleet "fleet prune" "$TERM_GLYPH_BRANCH $BASE_BRANCH" term_panel_vert if [[ $total -eq 0 ]]; then term_panel_line "no lane worktrees - nothing to classify" term_panel_vert term_panel_close "$(term_hotkey '?' help)" "$(term_health healthy "clean")" echo "" return 0 fi term_summary_line "$total worktree(s), $safe safe, $review review, $keep keep" term_panel_vert local b n rows p br bucket reason label short mark for b in SAFE REVIEW KEEP; do n=$(prune_count "$b") [[ $n -eq 0 ]] && continue case "$b" in SAFE) term_section READY "SAFE" "$n" ;; REVIEW) term_section CONFLICT "REVIEW" "$n" ;; KEEP) term_section RUNNING "KEEP" "$n" ;; esac rows=$(printf '%s' "$PRUNE_ROWS" | awk -F'\t' -v want="$b" 'NF && $3==want') while IFS=$'\t' read -r p br bucket reason; do [[ -z "$p" ]] && continue short=${p##*/} # ASCII-only marker: these rows must survive TERM_ASCII=1 on a non-UTF-8 # Windows console (SKILL.md "Compatibility"). mark=" " prune_is_native "$p" && mark="! " label="$mark$short" printf '%s %s %-30s %-26s %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_BRANCH$TERM_PANEL_HRULE")" \ "$(term_truncate "$label" 30)" \ "$(term_truncate "$br" 26)" \ "$(term_color dim "$reason")" done <<< "$rows" term_panel_vert done local health if [[ $safe -gt 0 ]]; then health="$(term_health pending "$safe removable")" else health="$(term_health healthy "nothing removable")"; fi term_panel_close "$(term_hotkey '?' help)" "$health" echo "" # '!' marks a native .claude/worktrees/ lane — see rules/worktree-boundaries.md if printf '%s' "$PRUNE_ROWS" | cut -f1 | grep -qi '/\.claude/worktrees/'; then echo " ! = Claude Code session worktree (.claude/worktrees/) - owned by a session, not by you" >&2 fi return 0 } prune_recovery_note() { echo " Committed lane work is NOT destroyed by removal - it lives in the shared" >&2 echo " object store and comes back with: git worktree add <path> <branch>" >&2 echo " Only uncommitted/untracked files are unrecoverable, which is why anything" >&2 echo " dirty is REVIEW and never SAFE." >&2 } prune_remove_safe() { local removed=0 skipped=0 failed=0 local p br bucket reason rows fresh dirty rows=$(printf '%s' "$PRUNE_ROWS" | awk -F'\t' 'NF && $3=="SAFE"') while IFS=$'\t' read -r p br bucket reason; do [[ -z "$p" ]] && continue # Re-verify immediately before deleting. Classification read a session index # with a long TTL (15 min); a session can wake between the table and the delete, and # this is the one operation where being one poll behind destroys data. fresh=$(lane_owner "$br" --fresh) if [[ -n "$fresh" && "$(sfield "$fresh" 7)" == "1" ]]; then prune_log "SKIP $p - owning session went LIVE since classification" skipped=$((skipped + 1)); continue fi dirty=$(prune_dirty_count "$p") if [[ "${dirty:-0}" -gt 0 ]]; then prune_log "SKIP $p - became dirty since classification ($dirty entries)" skipped=$((skipped + 1)); continue fi # `git worktree remove`, never `rm -rf`: it refuses a dirty or locked tree # (a third independent guard), and it also unregisters the worktree so the # repo is not left with a stale administrative entry. # stderr is captured rather than appended to $LOG: the log directory may not # exist (see prune_log), and a failed redirect would fail the command itself # — turning "could not remove" into an unexplained crash. local err="" if err=$(git -C "$REPO_ROOT" worktree remove "$p" 2>&1); then prune_log "removed worktree: $p (branch $br)" removed=$((removed + 1)) else prune_log "FAILED to remove $p - left in place: ${err:-unknown error}" failed=$((failed + 1)) fi done <<< "$rows" prune_log "prune: $removed removed, $skipped skipped, $failed failed" [[ $removed -gt 0 ]] && prune_recovery_note [[ $failed -eq 0 ]] } # Sibling-repo sweep. REPORT ONLY, and that is a design constraint, not a # limitation: a single command must never be able to sweep worktrees across the # machine. Acting on another repo means running `fleet prune` inside it, where # that repo's own base branch, config, and lane state apply — and where its own # session is the one taking the risk. PRUNE_REPOS=() # discovered repo dirs PRUNE_ROOTS_USED="" # human-readable roots, for the header PRUNE_TRUNCATED=0 # repos dropped by the cap — reported, never silent prune_discover_repos() { local roots=() if [[ $# -gt 0 ]]; then roots=("$@") elif [[ -n "${FLEET_PRUNE_ROOTS:-}" ]]; then # ';'-separated, NOT ':' — a Windows root is "D:/code" and would split. local IFS=';' r for r in $FLEET_PRUNE_ROOTS; do [[ -n "$r" ]] && roots+=("$r"); done else # Default: this repo's siblings. Broader than that is opt-in via --root, # because "scan the whole drive" is a different and much slower promise. roots=("$(dirname "$REPO_ROOT")") fi PRUNE_ROOTS_USED="${roots[*]}" local max=${FLEET_PRUNE_MAX_REPOS:-60} local root d seen=0 PRUNE_REPOS=(); PRUNE_TRUNCATED=0 for root in "${roots[@]}"; do root=${root%/} [[ -d "$root" ]] || { echo "fleet prune: root not a directory: $root" >&2; continue; } # One and two levels deep only. Deeper is a full-drive walk, and a nested # `.git` two levels down is already an unusual layout. for d in "$root"/*/ "$root"/*/*/; do [[ -d "$d" ]] || continue d=${d%/} # A `.git` FILE (not dir) means the dir is itself a worktree or submodule # — it has no worktrees of its own to prune. [[ -d "$d/.git" ]] || continue # …and a `.git` DIR is not proof either. If it isn't a valid repo, git # silently WALKS UP and answers for the enclosing repo instead, so the # parent's worktrees get counted a second time under the child's name # (caught by `tests/sample-project`, whose stub .git did exactly this). # Require the dir to be its own toplevel. local top dn top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || continue dn=$(prune_native "$d") [[ "$(prune_norm "$top")" == "$(prune_norm "$dn")" ]] || continue if [[ $seen -ge $max ]]; then PRUNE_TRUNCATED=$((PRUNE_TRUNCATED + 1)); continue; fi # Store git's own form so downstream paths match `git worktree list`. PRUNE_REPOS+=("$dn"); seen=$((seen + 1)) done done return 0 } # repo \t total \t safe \t review \t keep — stdout is data only. prune_all_repos_porcelain() { prune_discover_repos "$@" local repo base s rv k for repo in ${PRUNE_REPOS[@]+"${PRUNE_REPOS[@]}"}; do base=$(prune_base_for "$repo") prune_classify "$repo" "$base" s=$(prune_count SAFE); rv=$(prune_count REVIEW); k=$(prune_count KEEP) printf '%s\t%s\t%s\t%s\t%s\n' "$repo" "$((s + rv + k))" "$s" "$rv" "$k" done [[ $PRUNE_TRUNCATED -gt 0 ]] && \ echo "fleet prune: capped; $PRUNE_TRUNCATED repos skipped (raise FLEET_PRUNE_MAX_REPOS)" >&2 return 0 } prune_all_repos() { prune_discover_repos "$@" local roots="$PRUNE_ROOTS_USED" truncated=$PRUNE_TRUNCATED local n=0 for _ in ${PRUNE_REPOS[@]+"${PRUNE_REPOS[@]}"}; do n=$((n + 1)); done echo "" term_panel_open fleet "fleet prune --all-repos" "report only" term_panel_vert if [[ $n -eq 0 ]]; then term_panel_line "no git repositories found under: $roots" term_panel_vert term_panel_close "$(term_hotkey '?' help)" "$(term_health unknown "0 repos")" echo "" return 0 fi term_summary_line "$n repo(s) under $roots" term_panel_vert local repo base s rv k tot grand_safe=0 grand_rev=0 for repo in ${PRUNE_REPOS[@]+"${PRUNE_REPOS[@]}"}; do base=$(prune_base_for "$repo") prune_classify "$repo" "$base" s=$(prune_count SAFE); rv=$(prune_count REVIEW); k=$(prune_count KEEP) tot=$((s + rv + k)) [[ $tot -eq 0 ]] && continue grand_safe=$((grand_safe + s)); grand_rev=$((grand_rev + rv)) printf '%s %s %-34s %s\n' \ "$(term_color dim "$TERM_TREE_VERT")" \ "$(term_color dim "$TERM_TREE_BRANCH$TERM_PANEL_HRULE")" \ "$(term_truncate "${repo##*/}" 34)" \ "$(term_color dim "$tot worktrees, $s safe, $rv review, $k keep")" done term_panel_vert term_panel_close "$(term_hotkey '?' help)" "$(term_health pending "$grand_safe safe, $grand_rev review")" echo "" # No silent caps: if the sweep was bounded, say so. A truncated sweep that # reads as a complete one is worse than no sweep. [[ $truncated -gt 0 ]] && \ echo " NOTE: repo cap reached; $truncated more skipped (raise FLEET_PRUNE_MAX_REPOS)" >&2 echo " Report only. To act on a repo, run 'fleet prune' inside it - cross-repo" >&2 echo " removal is deliberately impossible from here." >&2 return 0 } prune_usage() { cat <<EOF fleet prune — classify (and optionally remove) finished lane worktrees fleet prune Classify and print. Changes NOTHING. (default) fleet prune --dry-run Same as above, said explicitly. fleet prune --remove Remove the SAFE rows, after a typed confirmation. fleet prune --remove --yes Remove without prompting (scripts/CI). fleet prune --all-repos Sibling-repo counts only; never removes. fleet prune --root <dir> Extra sweep root for --all-repos (repeatable). fleet prune --porcelain TSV to stdout, no panel. Report-only. path<TAB>branch<TAB>bucket<TAB>reason (with --all-repos: repo<TAB>total<TAB>sa -
sessions.sh 14.8 KB
#!/usr/bin/env bash # fleet-ops/sessions.sh — resolve which Claude session owns which lane. # # WHY THIS READS DISK AND NOT THE MCP TOOLS: the `ccd_session_mgmt` tools # (list_sessions/send_message/...) exist ONLY inside Claude Desktop. They are # absent from the terminal CLI binary entirely — verified 2026-08-03: the CLI # contains zero occurrences of `ccd_session_mgmt`, `list_sessions`, or # `spawn_task`, and its single `ccd_session` reference is a consumer-side # notification handler for a server the *host* injects. A script therefore # cannot call them. The underlying wrapper STORE, however, is plain JSON on # local disk and is readable from any shell on the machine — so this script # gets the same facts a Desktop tool would, and works in a terminal too. # # INVARIANTS # - stdout is DATA ONLY (TSV, one row per branch). Notes go to stderr. # - Never fails a caller: an absent store, absent jq, or a non-Desktop # machine exits 3 with empty stdout. Callers treat non-zero as "no info" # and carry on — this is an ENRICHMENT layer, never a hard dependency. # - Paths are normalised to forward slashes so they survive @tsv (which # escapes backslashes) and compare cleanly against git's output. # # Exit: 0 ok · 2 usage · 3 unavailable (no store / no jq) — advisory, not error. set -uo pipefail SELF=$(basename "$0") # Liveness threshold: a session touched within this many seconds counts as a # live writer. Desktop refreshes lastActivityAt per turn, so a session that is # open-but-thinking still reads live. 10 min matches summon's picker. LIVE_SECS=${FLEET_SESSION_LIVE_SECS:-600} usage() { cat <<EOF $SELF — map fleet lane branches to the Claude sessions that own them USAGE $SELF index All branch->session rows (TSV) $SELF owner [--fresh] <branch> The one session owning <branch> (newest wins). --fresh re-reads that session's liveness directly, bypassing the cache — use it for any gate that must not act on stale data. $SELF main The MAIN/coordinator session for this repo $SELF live <sessionId> 1 if that session is live, else 0 $SELF self The CALLING session's own store id, if it can be resolved and verified against the store. Exit 3 (silent) when it cannot. $SELF --help OUTPUT (TSV columns) branch sessionId title lastActivityMs cwd archived(0|1) live(0|1) ENVIRONMENT FLEET_SESSION_STORE override the session-store directory FLEET_SESSION_LIVE_SECS liveness window in seconds (default 600) FLEET_SESSION_CACHE_TTL index cache lifetime in seconds (default 900). Long by design — no gate reads cached liveness; they all use `owner --fresh`. FLEET_SESSION_NOCACHE set to any value to force a fresh scan EXAMPLES # who owns this lane, and are they still writing? $SELF owner lane/projection-control # the coordinator session for this repo $SELF main # every lane branch with a live owner $SELF index | awk -F'\\t' '\$7==1 {print \$1, \$3}' EXIT 0 ok (zero rows is still ok) 2 usage 3 store or jq unavailable EOF } # --- store discovery --------------------------------------------------------- # Desktop keeps one wrapper JSON per session under # <store>/<accountUuid>/<workspaceUuid>/local_<uuid>.json store_dir() { if [[ -n "${FLEET_SESSION_STORE:-}" ]]; then # Must still exist — an override pointing nowhere is "unavailable" (3), # not a hard error, so callers degrade rather than break. [[ -d "$FLEET_SESSION_STORE" ]] || return 1 printf '%s' "$FLEET_SESSION_STORE"; return fi local candidates=() # Windows: APPDATA is a native path in Git Bash; cygpath makes it POSIX. if [[ -n "${APPDATA:-}" ]]; then if command -v cygpath >/dev/null 2>&1; then candidates+=("$(cygpath -u "$APPDATA")/Claude/claude-code-sessions") else candidates+=("$APPDATA/Claude/claude-code-sessions") fi fi candidates+=( "$HOME/AppData/Roaming/Claude/claude-code-sessions" "$HOME/Library/Application Support/Claude/claude-code-sessions" "$HOME/.config/Claude/claude-code-sessions" ) local c for c in "${candidates[@]}"; do [[ -d "$c" ]] && { printf '%s' "$c"; return; } done return 1 } # Normalise a path for comparison: forward slashes, no trailing slash, # lowercased (Windows paths are case-insensitive and Desktop's casing of the # drive letter does not always match git's). norm_path() { local p=${1:-} p=${p//\\//} p=${p%/} printf '%s' "$p" | tr '[:upper:]' '[:lower:]' } # --- index ------------------------------------------------------------------- # One row per (branch, session). A session contributes its checked-out `branch` # AND every entry in `writtenBranches` — the latter is what actually matches a # fleet lane, because a session working in worktree `claude/foo-bar` may commit # its real work to `lane/thing`. # # CACHED, AND THE CACHE IS NOT OPTIONAL. A cold scan walks every wrapper in the # store (~900 branch rows here) and takes tens of seconds on Windows. `fleet # status` and the land gate call this repeatedly; uncached, five calls blew a # 2-minute timeout during development. TTL is short because the only field that # decays is liveness. # TTL is long ON PURPOSE. Nothing that DECIDES anything reads a cached liveness # value: `session_land_gate` and `prune_remove_safe` both call `owner --fresh`, # which re-reads the single owning wrapper and bypasses this cache entirely. # Cached rows feed display only (status annotations, `fleet main`, prune # classification — and the SAFE bucket is re-verified fresh before deletion). # A short TTL therefore bought no correctness and cost ~41s: a cold scan walks # the whole store, so at TTL=30 nearly every `fleet status` paid for one # (measured 2026-08-03: 47.1s cold vs 6.0s warm on an 11-worktree repo). CACHE_TTL=${FLEET_SESSION_CACHE_TTL:-900} cache_file() { local base="${TMPDIR:-/tmp}" printf '%s/fleet-sessions-%s.tsv' "${base%/}" "${UID:-$(id -u 2>/dev/null || echo 0)}" } build_index() { local cf; cf=$(cache_file) if [[ -z "${FLEET_SESSION_NOCACHE:-}" && -f "$cf" ]]; then local age=$(( $(date +%s) - $(file_mtime_s "$cf") )) if (( age >= 0 && age < CACHE_TTL )); then cat "$cf"; return 0 fi fi local out out=$(scan_store) || return $? printf '%s\n' "$out" > "$cf" 2>/dev/null || true printf '%s\n' "$out" } # Portable mtime-in-seconds (GNU stat -c, BSD/macOS stat -f). file_mtime_s() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0 } scan_store() { local sd sd=$(store_dir) || { echo "$SELF: no Claude session store on this machine" >&2; return 3; } command -v jq >/dev/null 2>&1 || { echo "$SELF: jq not found — session enrichment off" >&2; return 3; } local now_ms=$(( $(date +%s) * 1000 )) local live_ms=$(( LIVE_SECS * 1000 )) # Concatenated JSON objects are a valid jq input stream, so one cat + one jq # handles the whole store (hundreds of files) in two processes rather than # 2N. Piping also sidesteps the POSIX-vs-Windows path problem: a Windows jq # cannot open "/c/Users/..." but reads stdin fine. # Bounded by age: a lane older than the window is not a lane anyone is # about to land, and scanning the full historical store costs ~50s here vs # a few seconds for the recent slice. Unresolvable = "no info" = the gate # allows, so the window can only cost enrichment, never correctness. local age_days=${FLEET_SESSION_MAX_AGE_DAYS:-60} find "$sd" -name 'local_*.json' -type f -mtime "-${age_days}" -exec cat {} + 2>/dev/null | jq -r --argjson now "$now_ms" --argjson win "$live_ms" ' (.sessionId // "") as $id | (.title // "") as $t | ((.cwd // "") | gsub("\\\\"; "/")) as $cwd | (.lastActivityAt // 0) as $la | (if .isArchived == true then 1 else 0 end) as $arch | (if ($now - $la) <= $win then 1 else 0 end) as $live | ([ (.branch // empty) ] + (.writtenBranches // [])) | unique[] | select(type == "string" and . != "") | [ ., $id, $t, ($la|tostring), $cwd, ($arch|tostring), ($live|tostring) ] | @tsv ' 2>/dev/null # pipefail would surface find's exit on a vanished dir; the empty result is # the answer we want, so swallow it deliberately. return 0 } # Authoritative liveness for ONE session, bypassing the cache. # The cache trades staleness for speed, and stale-idle-but-actually-live is the # one direction that would let the land gate through when it should refuse. So # the gate re-reads just the owning wrapper — O(1), no store walk. # Echoes "1" (live) or "0". Unknown session → "0". session_live_now() { local id=$1 sd sd=$(store_dir) || { printf '0'; return; } command -v jq >/dev/null 2>&1 || { printf '0'; return; } local f f=$(find "$sd" -name "${id}.json" -type f 2>/dev/null | head -n1) [[ -n "$f" ]] || { printf '0'; return; } local la la=$(jq -r '.lastActivityAt // 0' <"$f" 2>/dev/null || echo 0) local now_ms=$(( $(date +%s) * 1000 )) if (( la > 0 && (now_ms - la) <= LIVE_SECS * 1000 )); then printf '1'; else printf '0'; fi } # --- self -------------------------------------------------------------------- # Which session is CALLING this script. # # WHY THIS EXISTS: the live-owner gate protects against landing a lane while a # session is still committing to it. When the session running `fleet land` is # itself that owner, the hazard is absent — it is blocked inside the land call # and cannot be mid-commit — but the gate could not tell the two apart, so a # lane session landing its own work always tripped it. Self-identity is what # separates "a PEER is writing" (refuse) from "I am the writer" (proceed). # # DELIBERATELY NOT OVERRIDABLE. There is no FLEET_SELF_SESSION_ID or equivalent: # a settable self-id would be a universal gate bypass wearing a different name # (export it to the owner's id and every refusal disappears). The id comes from # the harness, and is only believed once a wrapper file bearing it is found in # the store — so an unset, stale, or invented value resolves to nothing and the # gate keeps its full strength. Unresolvable self is the SAFE direction. self_session_id() { local sd; sd=$(store_dir) || return 3 # EVERY candidate is tried, not just the first one that is set. Inside # Desktop both CLAUDE_CODE_SESSION_ID and CLAUDE_CODE_HOST_SESSION_ID are # populated with DIFFERENT ids — the former is the CLI session, the latter # the host session the store is keyed by — so a first-set-wins chain # resolves nothing on exactly the surface this matters most on. local raw cand f for raw in "${CLAUDE_CODE_HOST_SESSION_ID:-}" "${CLAUDE_CODE_SESSION_ID:-}" \ "${CLAUDE_SESSION_ID:-}"; do [[ -n "$raw" ]] || continue # The harness may hand us the bare uuid or the store's `local_<uuid>` # form; the filename is always the latter. Try as-given first so a # future id shape that isn't uuid-based still resolves. for cand in "$raw" "local_$raw"; do f=$(find "$sd" -name "${cand}.json" -type f 2>/dev/null | head -n1) [[ -n "$f" ]] || continue basename "$f" .json return 0 done done return 3 } # --- owner ------------------------------------------------------------------- # Newest activity wins; a non-archived session outranks an archived one, since a # branch reused after its original session was archived belongs to the new one. cmd_owner() { local fresh=0 while [[ $# -gt 0 ]]; do case "$1" in --fresh) fresh=1; shift ;; -*) echo "$SELF: unknown flag '$1'" >&2; return 2 ;; *) break ;; esac done local branch=${1:-} [[ -z "$branch" ]] && { echo "usage: $SELF owner [--fresh] <branch>" >&2; return 2; } local idx idx=$(build_index) || return 3 local row row=$(printf '%s\n' "$idx" \ | awk -F'\t' -v want="$branch" '$1 == want' \ | sort -t"$(printf '\t')" -k6,6n -k4,4nr \ | head -n1) [[ -z "$row" ]] && return 0 if (( fresh )); then # Overwrite the cached liveness column with a direct read. local id; id=$(printf '%s' "$row" | cut -f2) local now; now=$(session_live_now "$id") row=$(printf '%s' "$row" | awk -F'\t' -v OFS='\t' -v L="$now" '{$7=L; print}') fi printf '%s\n' "$row" } # --- main -------------------------------------------------------------------- # MAIN = the coordinator session for this repo. Resolution order: # 1. explicit pin in .claude/fleet/main (a sessionId) — survives restarts and # lets a human override the heuristic # 2. the session whose cwd IS the repo root (not a worktree under it), newest # first, non-archived. This is the natural definition: worktree-boundaries # doctrine already says the base checkout is the landing tree, so whoever # sits in it is the integrator. cmd_main() { local root root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "$SELF: not in a git repo" >&2; return 2; } if command -v cygpath >/dev/null 2>&1; then root=$(cygpath -m "$root" 2>/dev/null || printf '%s' "$root") fi local want; want=$(norm_path "$root") local idx; idx=$(build_index) || return 3 # 1. explicit pin local pin_file="$root/.claude/fleet/main" pinned="" [[ -f "$pin_file" ]] && pinned=$(grep -v '^[[:space:]]*#' "$pin_file" 2>/dev/null | tr -d '[:space:]' | head -n1) if [[ -n "$pinned" ]]; then local hit hit=$(printf '%s\n' "$idx" | awk -F'\t' -v id="$pinned" '$2 == id' | sort -t"$(printf '\t')" -k4,4nr | head -n1) if [[ -n "$hit" ]]; then printf '%s\n' "$hit"; return 0; fi echo "$SELF: pinned MAIN $pinned not found in session store (stale pin?)" >&2 fi # 2. heuristic — cwd is exactly the repo root printf '%s\n' "$idx" \ | awk -F'\t' -v want="$want" ' { c=tolower($5); sub(/\/$/,"",c); if (c == want) print } ' \ | sort -t"$(printf '\t')" -k6,6n -k4,4nr \ | head -n1 } case "${1:---help}" in -h|--help|help) usage; exit 0 ;; index) build_index; exit $? ;; owner) shift; cmd_owner "$@"; exit $? ;; main) cmd_main; exit $? ;; live) shift; [[ -z "${1:-}" ]] && { echo "usage: $SELF live <sessionId>" >&2; exit 2; } session_live_now "$1"; echo; exit 0 ;; self) self_session_id || exit 3; exit 0 ;; *) echo "$SELF: unknown command '$1'" >&2; usage >&2; exit 2 ;; esac -
signal.sh 7.6 KB
#!/usr/bin/env bash # fleet-ops/signal.sh — called by Claude sessions to signal lane status # Auto-detects the current branch. Refuses dirty trees. # Resolves .fleet/ via git common dir, so it works from inside worktrees. set -euo pipefail GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null || true) [[ -z "$GIT_COMMON_DIR" ]] && { echo "signal.sh ERROR: not in a git repo" >&2; exit 2; } # git-common-dir is .git/ at main repo root → parent is the main worktree MAIN_REPO_ROOT=$(cd "$GIT_COMMON_DIR/.." && pwd) LANES_DIR="$MAIN_REPO_ROOT/.claude/fleet/lanes" BRANCH=$(git branch --show-current 2>/dev/null || true) if [[ -z "$BRANCH" ]]; then echo "signal.sh ERROR: not on a branch (detached HEAD?)" >&2 exit 2 fi # Lane files are flat: encode '/' in branch names (feat/x, fleet/x) so they don't # nest into nonexistent subdirs. MUST match fleet.sh's encode_lane. encode_lane() { local s=${1//\%/%25}; printf '%s' "${s//\//%2F}"; } LANE_FILE="$LANES_DIR/$(encode_lane "$BRANCH")" if [[ ! -f "$LANE_FILE" ]]; then echo "signal.sh ERROR: branch '$BRANCH' is not a registered lane (run: fleet track $BRANCH)" >&2 exit 2 fi # === LOG VERDICT === # Pass/fail from a test log, structured-signals-first. Grepping the whole log # for "failed|error" false-positives on any suite that exercises failure paths # (a PASSING run legitimately prints "email ... failed: No such module" to # stderr, and a passing test NAMED "...error..." trips the word too — both hit # on the Ledger repo, 2026-07). Order of trust: # 1. exit code — authoritative (explicit arg, or a trailing "exit code: N" # line the lane appended to the log) # 2. runner summary line (vitest/jest "Tests ...", pytest "=== N passed ===", # cargo "test result:", go "FAIL"/"ok") # 3. anchored fallback: count-shaped failure mentions ("N failed") only — # never a bare word-grep over the full log # Returns: 0 = pass, 1 = fail, 2 = no structured summary found. log_summary_verdict() { local log=$1 line # vitest (" Tests 2 failed | 10 passed (12)") / jest ("Tests: 2 failed, ..."). # "Test Files" (vitest) deliberately not matched — the "Tests" line is the verdict. line=$(grep -E '^[[:space:]]*Tests(:|[[:space:]])' "$log" | tail -n1 || true) if [[ -n "$line" ]]; then grep -qE '[1-9][0-9]*[[:space:]]+fail' <<<"$line" && return 1 || return 0 fi # pytest short summary: "==== 2 failed, 10 passed in 1.2s ====" line=$(grep -E '^=+ .*[0-9]+ (passed|failed|error).* =+$' "$log" | tail -n1 || true) if [[ -n "$line" ]]; then grep -qE '[1-9][0-9]*[[:space:]]+(failed|error)' <<<"$line" && return 1 || return 0 fi # cargo: "test result: ok. ..." / "test result: FAILED. ..." (last suite wins) line=$(grep -E '^test result: ' "$log" | tail -n1 || true) if [[ -n "$line" ]]; then case "$line" in "test result: ok."*) return 0;; *) return 1;; esac fi # go test: per-package "FAIL"/"--- FAIL:" lines, "ok <pkg>" on pass if grep -qE '^(FAIL|--- FAIL:)' "$log"; then return 1; fi if grep -qE '^ok[[:space:]]' "$log"; then return 0; fi return 2 } # === HANDOFF TO MAIN ========================================================== # Writing a lane file tells the QUEUE. It does not tell the human, and it does # not tell the coordinator session — which may be idle in another window with no # reason to poll. This prints the address of the MAIN session plus the exact # message to deliver, so the lane's agent can hand off explicitly. # # The script deliberately does NOT send anything. Delivery differs per surface: # - Desktop Claude -> mcp__ccd_session_mgmt__send_message (an MCP tool; a # shell script cannot invoke it, and it prompts the user # by design). The AGENT sends it. # - anywhere else -> pigeon, which is a real CLI and works in a terminal and # for non-Claude harnesses. # Resolving the address is the part a script can do well; sending is not. announce_main() { local state=$1 detail=${2:-} local sessions="$MAIN_REPO_ROOT/.claude/fleet/sessions.sh" [[ -f "$sessions" ]] || return 0 local row; row=$(bash "$sessions" main 2>/dev/null) || return 0 [[ -z "$row" ]] && return 0 local id title live id=$(printf '%s' "$row" | cut -f2) title=$(printf '%s' "$row" | cut -f3) live=$(printf '%s' "$row" | cut -f7) { echo echo "handoff -> MAIN: '$title' ($id)$([[ "$live" == "1" ]] && echo ' [live]' || echo ' [idle]')" echo " If you are a Claude Desktop session, deliver this now with" echo " mcp__ccd_session_mgmt__send_message to session_id $id:" echo echo " lane $BRANCH is $state${detail:+ ($detail)}. Requesting land via fleet-ops." echo echo " Not on Desktop (terminal, headless, or a non-Claude harness)?" echo " Use the portable bus instead:" echo " pigeon send $(basename "$MAIN_REPO_ROOT") \"lane $BRANCH $state\" \"Requesting land.\"" } >&2 } # === END HANDOFF ============================================================== STATE=${1:-} case "$STATE" in READY) if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then echo "signal.sh REFUSE: '$BRANCH' has uncommitted tracked changes — commit or stash before signaling READY" >&2 git status --short >&2 exit 1 fi LOG=${2:-} RC=${3:-} if [[ -n "$RC" && ! "$RC" =~ ^[0-9]+$ ]]; then echo "signal.sh ERROR: exit-code arg '$RC' is not numeric" >&2; exit 1 fi if [[ -n "$LOG" ]]; then [[ -f "$LOG" ]] || { echo "signal.sh ERROR: test log '$LOG' not found" >&2; exit 1; } # No explicit exit-code arg → accept an "exit code: N" line lanes append # to the log (the workaround two lanes independently invented; now contract). if [[ -z "$RC" ]]; then RC=$(grep -iE '^[[:space:]]*exit code:[[:space:]]*[0-9]+' "$LOG" | tail -n1 | grep -oE '[0-9]+' | tail -n1 || true) fi if [[ -n "$RC" ]]; then if (( RC != 0 )); then echo "signal.sh REFUSE: test command exited $RC (log '$LOG')" >&2 tail -n 10 "$LOG" >&2 exit 1 fi # exit 0 is authoritative — no log grep can overrule it elif log_summary_verdict "$LOG"; then : # structured summary says pass else v=$? if (( v == 1 )); then echo "signal.sh REFUSE: test log '$LOG' summary shows failures" >&2 grep -E '^[[:space:]]*Tests(:|[[:space:]])|^=+ .*=+$|^test result: |^(FAIL|--- FAIL:)' "$LOG" | tail -n 5 >&2 exit 1 fi # No exit code, no recognized summary → anchored fallback: refuse only # on count-shaped failure lines ("2 failed", "1 failing", "3 errors"), # never on prose containing the bare words. if grep -qiE '\b[1-9][0-9]*[[:space:]]+(failed|failing|errors?)\b' "$LOG"; then echo "signal.sh REFUSE: test log '$LOG' shows failures" >&2 grep -iE '\b[1-9][0-9]*[[:space:]]+(failed|failing|errors?)\b' "$LOG" | head -5 >&2 exit 1 fi fi fi { echo "READY"; [[ -n "$LOG" ]] && echo "log=$LOG"; } > "$LANE_FILE" echo "signal: $BRANCH → READY" announce_main READY "tests green" ;; CONFLICT) REASON=${2:-"unspecified"} { echo "CONFLICT"; echo "reason=$REASON"; } > "$LANE_FILE" echo "signal: $BRANCH → CONFLICT ($REASON)" # A CONFLICT is exactly the case where silence costs most: the lane cannot # land itself and MAIN is the only session that can triage it. announce_main CONFLICT "$REASON" ;; RUNNING) echo "RUNNING" > "$LANE_FILE" echo "signal: $BRANCH → RUNNING" ;; *) echo "usage: signal.sh READY [test-log] [exit-code] | CONFLICT [reason] | RUNNING" >&2 exit 1 ;; esac
-
-
tests
-
run.sh 60.4 KB
#!/usr/bin/env bash # Self-test for fleet-ops. Offline + deterministic (git only, no network). # Primary focus: the lane-file encoding regression — branch names containing # '/' (feat/x, fleet/x, the convention fleet-worker emits) must track, signal, # land, display, and revert correctly, not nest into a nonexistent subdir. # Resolves paths relative to itself so it runs in the repo and once installed. # # Usage: bash tests/run.sh # Exit: 0 all pass, 1 one or more failures (SKIP+exit 0 if git is unavailable) set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" FLEET="$SKILL/scripts/fleet.sh" export TERM_ASCII=1 # Hermetic env: the CALLER's fleet knobs must never reach the logic under test. # The canonical failure (2026-09-01): `FLEET_SKIP_SESSION_CHECK=1 fleet land` # ran this suite as its post-merge test_cmd, the exported override inherited, # and the live-owner gate this suite asserts REFUSES was disarmed inside every # sandbox — 6 false FAILs hard-reset a green merge. fleet.sh now strips these # before test_cmd, but the suite must not depend on its callers being fixed. # Cases that WANT an override set it explicitly on their own command line. unset FLEET_SKIP_SESSION_CHECK FLEET_SESSION_STORE FLEET_SESSION_NOCACHE \ FLEET_SESSION_LIVE_SECS FLEET_SESSION_CACHE_TTL FLEET_SESSION_MAX_AGE_DAYS \ FLEET_SELF_SESSION_ID FLEET_NO_PRUNE_HINT FLEET_PRUNE_ROOTS \ FLEET_PRUNE_MAX_REPOS FLEET_ASCII command -v git >/dev/null 2>&1 || { echo "SKIP: git not available"; exit 0; } SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT PASS=0; FAIL=0 ok(){ PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } no(){ FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } ee(){ [ "$2" = "$3" ] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; } # Every land needs an ARMED test gate: fleet.sh REFUSES to land when no # test_cmd resolves, because an unarmed gate used to merge without running # anything. Fixtures that are not themselves about the gate arm it with a # trivially-passing command, so they exercise the path they actually mean to # test rather than tripping the refusal. The refusal itself is asserted # explicitly further down ("absent test_cmd"). arm_gate(){ mkdir -p "$1/.claude/fleet"; printf 'test_cmd=true\n' > "$1/.claude/fleet/config"; } echo "=== fleet-ops self-test ===" REPO="$SB/repo"; mkdir -p "$REPO" git -C "$REPO" init -q -b main git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t git -C "$REPO" config core.autocrlf false echo base > "$REPO/f"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init arm_gate "$REPO" # Create branch $1 with one commit touching unique file $2, in its own worktree. mk_lane(){ local br=$1 file=$2 wt="$SB/wt-$(printf '%s' "$1" | tr / _)" git -C "$REPO" branch "$br" main git -C "$REPO" worktree add -q "$wt" "$br" echo "$br" > "$wt/$file" git -C "$wt" add -A git -C "$wt" -c user.email=w@t -c user.name=w commit -qm "work $br" } mk_lane "fleet/task-a" a.txt mk_lane "feat/foo" b.txt mk_lane "plain" c.txt cd "$REPO" echo "-- track (the regression: slashed names must not fail) --" bash "$FLEET" track fleet/task-a feat/foo plain >/dev/null 2>&1; ee "track slashed + plain" 0 $? [ -f "$REPO/.claude/fleet/lanes/fleet%2Ftask-a" ] && ok "slashed lane stored flat-encoded" || no "encoded lane file missing" [ -f "$REPO/.claude/fleet/lanes/feat%2Ffoo" ] && ok "feat/foo lane flat-encoded" || no "feat/foo lane missing" [ -f "$REPO/.claude/fleet/lanes/plain" ] && ok "plain lane stored as-is" || no "plain lane missing" # No stray nested subdir was created. [ -d "$REPO/.claude/fleet/lanes/fleet" ] && no "stray nested lanes/fleet/ subdir exists" || ok "no nested subdir leaked" echo "-- status decodes filenames back to branch names --" st="$(bash "$FLEET" status 2>&1)" case "$st" in *"fleet/task-a"*) ok "status shows decoded fleet/task-a";; *) no "status missing fleet/task-a";; esac case "$st" in *"feat/foo"*) ok "status shows decoded feat/foo";; *) no "status missing feat/foo";; esac echo "-- signal.sh on a slashed branch (deployed copy) --" ( cd "$SB/wt-fleet_task-a" && bash "$REPO/.claude/fleet/signal.sh" READY ) >/dev/null 2>&1 ee "signal READY on slashed branch" 0 $? case "$(head -n1 "$REPO/.claude/fleet/lanes/fleet%2Ftask-a" 2>/dev/null)" in READY) ok "signal recorded READY";; *) no "READY not recorded";; esac echo "-- land records state and merges --" bash "$FLEET" land fleet/task-a >/dev/null 2>&1; ee "land slashed branch" 0 $? case "$(head -n1 "$REPO/.claude/fleet/lanes/fleet%2Ftask-a" 2>/dev/null)" in LANDED) ok "lane state LANDED recorded";; *) no "LANDED not recorded";; esac # Never assert via `git log | grep -q` here: under `set -o pipefail`, grep -q # exits at the first match and git log dies with SIGPIPE (141), flaking the # pipeline non-zero even when the merge commit exists. Capture, then match. main_log="$(git -C "$REPO" log --oneline main)" case "$main_log" in *"merge: fleet/task-a"*) ok "merge commit on main";; *) no "no merge commit";; esac echo "-- one-shot revert --" bash "$FLEET" revert fleet/task-a >/dev/null 2>&1; ee "revert slashed branch" 0 $? echo "-- land --all batch-lands READY lanes oldest-first --" # 'plain' is still tracked (RUNNING from the initial track). Add a second lane, # mark both READY, and batch-land in one pass. mk_lane "feat/batch-b" d.txt bash "$FLEET" track feat/batch-b >/dev/null 2>&1 ( cd "$SB/wt-plain" && bash "$REPO/.claude/fleet/signal.sh" READY ) >/dev/null 2>&1 ( cd "$SB/wt-feat_batch-b" && bash "$REPO/.claude/fleet/signal.sh" READY ) >/dev/null 2>&1 bash "$FLEET" land --all >/dev/null 2>&1; ee "land --all exits 0 (all READY landed)" 0 $? case "$(head -n1 "$REPO/.claude/fleet/lanes/plain" 2>/dev/null)" in LANDED) ok "land --all landed 'plain'";; *) no "'plain' not LANDED after land --all";; esac case "$(head -n1 "$REPO/.claude/fleet/lanes/feat%2Fbatch-b" 2>/dev/null)" in LANDED) ok "land --all landed feat/batch-b";; *) no "feat/batch-b not LANDED after land --all";; esac main_log="$(git -C "$REPO" log --oneline main)" # captured, not piped — see SIGPIPE note above case "$main_log" in *"merge: plain"*) ok "merge: plain on main";; *) no "no merge: plain on main";; esac case "$main_log" in *"merge: feat/batch-b"*) ok "merge: feat/batch-b on main";; *) no "no merge: feat/batch-b on main";; esac # A RUNNING lane (feat/foo, not signalled READY) must be left untouched by the default batch. case "$(head -n1 "$REPO/.claude/fleet/lanes/feat%2Ffoo" 2>/dev/null)" in RUNNING) ok "land --all left RUNNING feat/foo untouched";; *) no "land --all wrongly touched RUNNING lane";; esac echo "-- TERM_ASCII=1 renders the WHOLE panel in ASCII, not just the tree rail --" # Regression: `fleet status` leaked a literal U+00B7 (0xC2 0xB7) from the summary # line and the footer hotkeys under TERM_ASCII=1 — those separators were authored # inline instead of coming from term.sh's $TERM_DOT, so the ASCII registry never # saw them and non-UTF-8 Windows consoles mojibaked. Assert the ENTIRE emission, # every view: a single-glyph check (the old "no │" test) cannot catch a sibling. # FORCE_COLOR=1 keeps the ANSI path live so color codes can't hide a glyph. # Lanes here span RUNNING + LANDED, so sections, rails, leaf rows, the summary # line and the footer are all exercised in one shot. ascii_pure() { # label, output local dirty dirty="$(printf '%s' "$2" | LC_ALL=C grep -o '[^[:print:][:cntrl:]]' | LC_ALL=C sort -u | tr -d '\n')" if [ -n "$dirty" ]; then no "$1 emits non-ASCII under TERM_ASCII=1 ($(printf '%s' "$dirty" | od -An -c | tr -s ' '))" else ok "$1 is pure ASCII under TERM_ASCII=1"; fi } ascii_pure "status panel" "$(TERM_ASCII=1 FORCE_COLOR=1 bash "$FLEET" status 2>&1)" ascii_pure "verbose panel" "$(TERM_ASCII=1 FORCE_COLOR=1 bash "$FLEET" status --verbose 2>&1)" # FLEET_ASCII is the legacy alias SKILL.md advertises as the mojibake fix — it # must reach exactly the same registry, or the documented remedy is a lie. ascii_pure "status (FLEET_ASCII=1)" "$(env -u TERM_ASCII FLEET_ASCII=1 FORCE_COLOR=1 bash "$FLEET" status 2>&1)" # Empty state renders a different branch of the panel (tip glyph, no sections). EREPO="$SB/empty"; mkdir -p "$EREPO" git -C "$EREPO" init -q -b main git -C "$EREPO" config user.email t@t; git -C "$EREPO" config user.name t git -C "$EREPO" config core.autocrlf false echo e > "$EREPO/f"; git -C "$EREPO" add -A git -C "$EREPO" -c user.email=t@t -c user.name=t commit -qm init ascii_pure "empty-state panel" "$(cd "$EREPO" && TERM_ASCII=1 FORCE_COLOR=1 bash "$FLEET" status 2>&1)" # Guard the other direction too: without TERM_ASCII the panel must still use the # Unicode glyphs, or "pure ASCII" would pass trivially by rendering nothing. uni_out="$(cd "$REPO" && env -u TERM_ASCII -u FLEET_ASCII LC_ALL=en_US.UTF-8 bash "$FLEET" status 2>&1)" case "$uni_out" in *"$(printf '\xc2\xb7')"*) ok "unicode mode still renders the U+00B7 separator";; *) no "unicode mode lost its separator (ASCII fallback leaked into UTF-8 output)";; esac echo "-- scrub gate still works on a slashed branch --" wt="$SB/wt-feat_foo" # Marker built via printf so this source file never contains the contiguous # forbidden token — otherwise any later diff hunk near this line drags it into # a hunk header / context line and scrub-check false-positives on run.sh itself. printf 'TODO_%s leftover\n' 'SCRUB' >> "$wt/b.txt" git -C "$wt" -c user.email=w@t -c user.name=w commit -aqm "oops debug marker" bash "$FLEET" scrub-check feat/foo >/dev/null 2>&1; ee "scrub-check flags forbidden pattern" 1 $? echo "-- scrub gate ignores deletions and context (added lines only) --" # Regression (2026-07): scrub_diff grepped the raw diff, so a branch REMOVING a # forbidden marker (a '-' line), or a marker landing in a hunk header / context # line near an unrelated edit, false-refused. Only '+' lines are violations. printf 'TODO_%s cleanup-me\n' 'SCRUB' >> "$REPO/f" git -C "$REPO" commit -qam "main carries a marker" mk_lane "chore/descrub" e.txt # branches off main, so it inherits the marker grep -v "cleanup-me" "$SB/wt-chore_descrub/f" > "$SB/wt-chore_descrub/f.tmp" && mv "$SB/wt-chore_descrub/f.tmp" "$SB/wt-chore_descrub/f" git -C "$SB/wt-chore_descrub" -c user.email=w@t -c user.name=w commit -qam "remove stale marker" bash "$FLEET" scrub-check chore/descrub >/dev/null 2>&1; ee "scrub-check passes marker REMOVAL" 0 $? echo "-- scrub gate: mktemp X-templates pass, lone triple-X markers refuse --" # Regression (2026-09-01): the old default's X-term matched the 4th X of a # mktemp template (uppercase X is not [a-z]), refusing any branch that added # TMP="$(mktemp -t push-gate-paths.<six X's>)" # even though identical templates already lived on main. Runs of 4+ X's are # templates; only a lone triple-X followed by a non-letter is a marker. Both # tokens are BUILT at runtime — a contiguous triple-X (or marker) in this # source would trip the gate on run.sh itself (see the marker note above). XR='XX' mk_lane "chore/mktempl" tmpl.sh printf 'TMP="$(mktemp -t push-gate-paths.%s)"\n' "$XR$XR$XR" >> "$SB/wt-chore_mktempl/tmpl.sh" git -C "$SB/wt-chore_mktempl" -c user.email=w@t -c user.name=w commit -qam "add mktemp template" bash "$FLEET" scrub-check chore/mktempl >/dev/null 2>&1; ee "mktemp X-template passes scrub" 0 $? mk_lane "chore/xmark" mark.txt printf '%sX fix this later\n' "$XR" >> "$SB/wt-chore_xmark/mark.txt" git -C "$SB/wt-chore_xmark" -c user.email=w@t -c user.name=w commit -qam "add a marker" bash "$FLEET" scrub-check chore/xmark >/dev/null 2>&1; ee "lone triple-X marker still refused" 1 $? echo "-- signal.sh log gate: exit codes and summaries, not prose --" # Regression (Ledger, 2026-07): a GREEN run whose stderr prints "failed"/"error" # prose, or whose test NAMES contain "error", must not be refused. Verdict order # under test: exit-code arg > "exit code: N" log line > runner summary > anchored # count fallback. Uses feat/foo's worktree (clean tree, still a registered lane). SIGWT="$SB/wt-feat_foo" green_log="$SB/green-vitest.log" cat > "$green_log" <<'EOF' stderr | email to ledger@ev7.com.au failed: No such module "queue" v src/mail.test.ts > logs an error when sending fails v src/mail.test.ts > surfaces the failed delivery to the caller Test Files 3 passed (3) Tests 42 passed (42) Start at 10:00:00 EOF ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$green_log" ) >/dev/null 2>&1 ee "green vitest log with 'failed' prose passes" 0 $? red_log="$SB/red-vitest.log" cat > "$red_log" <<'EOF' x src/mail.test.ts > sends the digest Test Files 1 failed | 2 passed (3) Tests 2 failed | 40 passed (42) EOF ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$red_log" ) >/dev/null 2>&1 ee "failing vitest summary refused" 1 $? # Exit code is authoritative in BOTH directions: rc=0 overrules scary prose # with no recognizable summary; rc=1 overrules a log that looks clean. prose_log="$SB/prose.log" printf 'connection error simulated: retry failed as expected\nall scenarios ok\n' > "$prose_log" ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$prose_log" 0 ) >/dev/null 2>&1 ee "rc=0 arg passes despite prose" 0 $? ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$prose_log" 1 ) >/dev/null 2>&1 ee "rc=1 arg refused despite clean-looking log" 1 $? # The lane-appended "exit code: N" line (the workaround that exposed the bug). printf 'connection error simulated: retry failed as expected\nexit code: 0\n' > "$prose_log" ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$prose_log" ) >/dev/null 2>&1 ee "'exit code: 0' log line passes" 0 $? # pytest failing summary still caught without any exit code. py_red="$SB/red-pytest.log" printf '=========== 2 failed, 10 passed in 1.24s ===========\n' > "$py_red" ( cd "$SIGWT" && bash "$REPO/.claude/fleet/signal.sh" READY "$py_red" ) >/dev/null 2>&1 ee "failing pytest summary refused" 1 $? echo "-- config parsing: every documented form must actually reach the script --" # Regression (2026-07-28): .claude/fleet/config was `source`d, so (a) documented # lowercase keys set shell vars the UPPERCASE-reading script never looked at, and # (b) an unquoted value with spaces isn't a bash assignment at all — the error was # swallowed by 2>/dev/null. Net effect: `fleet land` never ran a test gate, ever. # These assertions pin the parser to the grammar SKILL.md documents. CREPO="$SB/cfgrepo"; mkdir -p "$CREPO" git -C "$CREPO" init -q -b main git -C "$CREPO" config user.email t@t; git -C "$CREPO" config user.name t git -C "$CREPO" config core.autocrlf false echo base > "$CREPO/f"; git -C "$CREPO" add -A; git -C "$CREPO" commit -qm init mkdir -p "$CREPO/.claude/fleet" CFG="$CREPO/.claude/fleet/config" cd "$CREPO" # Resolved value of one key, via the `fleet config` dump (stdout is data-only). cfg_get(){ bash "$FLEET" config 2>/dev/null | sed -n "s/^$1=//p"; } eq(){ [ "$2" = "$3" ] && ok "$1" || no "$1 (want [$2] got [$3])"; } rm -f "$CFG" eq "absent config → test_cmd empty (defaults)" "" "$(cfg_get test_cmd)" eq "absent config → base_branch default" "main" "$(cfg_get base_branch)" printf 'test_cmd=echo hi\n' > "$CFG" eq "lowercase key reaches TEST_CMD" "echo hi" "$(cfg_get test_cmd)" printf 'TEST_CMD=echo hi\n' > "$CFG" eq "UPPERCASE key reaches TEST_CMD" "echo hi" "$(cfg_get test_cmd)" # The exact form that used to parse as "run `-m` with test_cmd in its env". printf 'test_cmd=uv run pytest -q --maxfail=1 tests/\n' > "$CFG" eq "unquoted value WITH SPACES survives" "uv run pytest -q --maxfail=1 tests/" "$(cfg_get test_cmd)" printf 'test_cmd="npm run check -- --fast"\n' > "$CFG" eq "double-quoted value with spaces, quotes stripped" "npm run check -- --fast" "$(cfg_get test_cmd)" printf "test_cmd='npm run check'\n" > "$CFG" eq "single-quoted value with spaces, quotes stripped" "npm run check" "$(cfg_get test_cmd)" # Comments, blanks, indentation, and the trailing-comment annotation used in the # SKILL.md example block — a verbatim copy of the docs must work. cat > "$CFG" <<'EOF' # fleet-ops config mode=worktree # auto | worktree | branch test_cmd=make check poll_interval=9 EOF eq "comment + blank lines ignored, mode parsed" "worktree" "$(cfg_get mode)" eq "trailing ' # comment' stripped from unquoted value" "make check" "$(cfg_get test_cmd)" eq "indented key parsed" "9" "$(cfg_get poll_interval)" # Quoting is how you keep a literal '#' — forbidden_pattern is the real case. printf 'forbidden_pattern="TODO_MARK|#nolint"\n' > "$CFG" eq "quoted value keeps literal #" 'TODO_MARK|#nolint' "$(cfg_get forbidden_pattern)" # Failures must be LOUD. A config that yields nothing is not an absent config. printf 'tets_cmd=echo hi\n' > "$CFG" warn="$(bash "$FLEET" config 2>&1 >/dev/null)" case "$warn" in *"unrecognised key 'tets_cmd'"*) ok "typo'd key warns by name";; *) no "typo'd key silently ignored";; esac case "$warn" in *"set no recognised keys"*) ok "no-recognised-keys config warns";; *) no "no warning for inert config";; esac # fleet.sh cds to the repo root, so $CONFIG (and every warning) is root-relative. case "$warn" in *".claude/fleet/config"*) ok "warning names the config file";; *) no "warning omits file path";; esac eq "inert config keeps defaults" "" "$(cfg_get test_cmd)" printf 'this is not a key value line\ntest_cmd=echo hi\n' > "$CFG" warn="$(bash "$FLEET" config 2>&1 >/dev/null)" case "$warn" in *"not a key=value line"*) ok "malformed line warns";; *) no "malformed line silent";; esac eq "malformed line doesn't stop later keys" "echo hi" "$(cfg_get test_cmd)" printf 'poll_interval=soon\n' > "$CFG" warn="$(bash "$FLEET" config 2>&1 >/dev/null)" case "$warn" in *"poll_interval must be an integer"*) ok "non-numeric poll_interval warns";; *) no "non-numeric poll_interval silent";; esac eq "non-numeric poll_interval keeps default" "5" "$(cfg_get poll_interval)" # CRLF-authored config (Windows editors) must not leave \r glued to the value. printf 'test_cmd=echo hi\r\nbase_branch=main\r\n' > "$CFG" eq "CRLF config parsed without trailing CR" "echo hi" "$(cfg_get test_cmd)" # `icons=` is read by term_init, which used to run BEFORE the config loaded. # Only the ascii direction is asserted: term.sh also auto-selects ASCII on a # non-UTF8 locale, so a "unicode" assertion would flake in CI. printf 'icons=ascii\n' > "$CFG" eq "icons key parsed" "ascii" "$(cfg_get icons)" bash "$FLEET" track main >/dev/null 2>&1 || true # Asserts the whole panel, not just the tree rail: `icons=ascii` is the config # route into term_init, so it must deliver the same ASCII purity TERM_ASCII=1 does. ascii_pure "icons=ascii panel" "$(env -u TERM_ASCII -u FLEET_ASCII FORCE_COLOR=1 bash "$FLEET" status 2>&1)" rm -f "$CREPO/.claude/fleet/lanes/main" echo "-- test gate: test_cmd actually runs and actually blocks the merge --" # End-to-end proof of the whole point of the fix. The gate command is written # UNQUOTED WITH SPACES — the exact form that silently no-op'd before. printf 'test_cmd=grep -q ok ./gate.txt\n' > "$CFG" mk_cfg_lane(){ # branch, file git -C "$CREPO" branch "$1" main git -C "$CREPO" worktree add -q "$SB/cwt-$1" "$1" echo "$1" > "$SB/cwt-$1/$2" git -C "$SB/cwt-$1" add -A git -C "$SB/cwt-$1" -c user.email=w@t -c user.name=w commit -qm "work $1" } # Red: gate fails → merge must be undone and the lane marked FAILED. echo bad > "$CREPO/gate.txt" mk_cfg_lane red-lane r.txt before="$(git -C "$CREPO" rev-parse main)" bash "$FLEET" track red-lane >/dev/null 2>&1 land_out="$(bash "$FLEET" land red-lane 2>&1)"; rc=$? ee "land exits non-zero when test_cmd fails" 1 $rc case "$land_out" in *"running test_cmd: grep -q ok ./gate.txt"*) ok "log shows the test_cmd being run";; *) no "no 'running test_cmd' log line — gate did not run";; esac eq "failed gate hard-resets the merge" "$before" "$(git -C "$CREPO" rev-parse main)" case "$(head -n1 "$CREPO/.claude/fleet/lanes/red-lane" 2>/dev/null)" in FAILED) ok "lane marked FAILED after gate failure";; *) no "lane not FAILED after gate failure";; esac # Green: gate passes → normal land. echo ok > "$CREPO/gate.txt" mk_cfg_lane green-lane g.txt bash "$FLEET" track green-lane >/dev/null 2>&1 bash "$FLEET" land green-lane >/dev/null 2>&1; ee "land succeeds when test_cmd passes" 0 $? case "$(head -n1 "$CREPO/.claude/fleet/lanes/green-lane" 2>/dev/null)" in LANDED) ok "lane LANDED after gate passes";; *) no "lane not LANDED after passing gate";; esac cfg_log="$(git -C "$CREPO" log --oneline main)" # captured, not piped — SIGPIPE note above case "$cfg_log" in *"merge: green-lane"*) ok "merge commit kept after passing gate";; *) no "merge commit missing";; esac # Absent test_cmd REFUSES the land. It used to fall through to signal.sh's log # gate, which verifies nothing when a lane signalled READY without a test log — # so the branch merged having run zero tests, reported only as a log line. The # config is gitignored in most repos, so "absent" is the fresh-clone/git-clean # case, not an exotic one. Assert the refusal AND that nothing moved. rm -f "$CFG" mk_cfg_lane nogate-lane n.txt bash "$FLEET" track nogate-lane >/dev/null 2>&1 before_nogate="$(git -C "$CREPO" rev-parse main)" land_out="$(bash "$FLEET" land nogate-lane 2>&1)"; rc=$? ee "absent test_cmd refuses the land" 1 $rc case "$land_out" in *"UNARMED"*) ok "refusal says the gate is unarmed";; *) no "refusal message unclear";; esac case "$land_out" in *".claude/fleet/config"*) ok "refusal names the config path";; *) no "refusal omits the config path";; esac eq "refused land leaves the base branch untouched" "$before_nogate" "$(git -C "$CREPO" rev-parse main)" case "$(git -C "$CREPO" log --oneline main)" in *"merge: nogate-lane"*) no "unarmed gate merged anyway";; *) ok "no merge commit from an unarmed gate";; esac # The lane is NOT marked CONFLICT: an unarmed gate is a repo-level fault, not # the lane's, so a human isn't left undoing state that was never wrong. case "$(head -n1 "$CREPO/.claude/fleet/lanes/nogate-lane" 2>/dev/null)" in CONFLICT) no "refusal wrongly marked the lane CONFLICT";; *) ok "refusal leaves lane state alone";; esac # The daemon must refuse to start too, rather than spin refusing every poll. bash "$FLEET" start >/dev/null 2>&1; ee "daemon refuses to start unarmed" 1 $? # -- already-merged branch: the two-sessions-one-branch case ------------------- # Regression, reproduced 2026-09-08 in a downstream repo (branch # claude/charming-mendel-4ebf5d landed twice, 90 seconds apart). `git merge # --no-ff` exits 0 with "Already up to date." when the branch is already an # ancestor of the base, so land_one took the success path for a merge it never # made: # (a) it logged `PASS: <branch> landed` and returned 0 for a no-op land, and # (b) when the gate then failed it ran `git reset --hard HEAD^`, discarding a # merge commit ANOTHER session had created — a peer's landed work, thrown # away on a branch fleet believed it owned. In the incident the gate # happened to pass; that was luck, not design. # Case (b) is also the only case that can tell `reset --hard $before` apart from # `reset --hard HEAD^`: after a genuine --no-ff merge those name the same commit. echo "-- already-merged branch: no false land, no peer-destroying reset --" MREPO="$SB/mrepo"; mkdir -p "$MREPO" git -C "$MREPO" init -q -b main git -C "$MREPO" config user.email t@t; git -C "$MREPO" config user.name t git -C "$MREPO" config core.autocrlf false echo base > "$MREPO/f"; git -C "$MREPO" add -A; git -C "$MREPO" commit -qm init mkdir -p "$MREPO/.claude/fleet" MCFG="$MREPO/.claude/fleet/config" cd "$MREPO" # Lane with one commit at a FIXED timestamp — `land --all` orders by commit # time, and same-second ties would make the batch order (and so the tally # assertion in (e)) nondeterministic. The worktree is dropped afterwards: while # a branch is checked out anywhere, `git branch -d` cannot delete it, so "the # lane branch survived" would prove nothing about whether fleet tried to. mk_mlane(){ # repo, branch, file, epoch local wt="$SB/mwt-$2" git -C "$1" branch "$2" main git -C "$1" worktree add -q "$wt" "$2" echo "$2" > "$wt/$3" git -C "$wt" add -A GIT_AUTHOR_DATE="@$4 +0000" GIT_COMMITTER_DATE="@$4 +0000" \ git -C "$wt" -c user.email=w@t -c user.name=w commit -qm "work $2" git -C "$1" worktree remove --force "$wt" >/dev/null 2>&1 || true } # What the OTHER session does: a real --no-ff merge, made outside fleet. peer_land(){ git -C "$1" merge "$2" --no-ff -m "merge: $2" -q; } # (a) already merged, green gate -> reported as already landed, not as a land. # The gate is side-effecting, so "did it run?" is a file test rather than # prose-matching: there is no merge of ours to gate, so it must not run. printf 'test_cmd=touch ./gate-ran\n' > "$MCFG" mk_mlane "$MREPO" dup-green x.txt 1700000000 peer_land "$MREPO" dup-green bash "$FLEET" track dup-green >/dev/null 2>&1 # AFTER the track, deliberately: ensure_fleet_dir appends .claude/fleet/ and # .fleet-worktrees/ to .gitignore and auto-commits that on base_branch, so a tip # captured before tracking is already stale. The invariant under test is "the # LAND does not move the tip", so capture it immediately before the land. peer_sha="$(git -C "$MREPO" rev-parse main)" land_out="$(bash "$FLEET" land dup-green 2>&1)"; rc=$? ee "already-merged branch lands cleanly" 0 $rc eq "already-merged land leaves the base tip untouched" "$peer_sha" "$(git -C "$MREPO" rev-parse main)" case "$land_out" in *"ALREADY LANDED: dup-green"*) ok "log uses a distinct ALREADY LANDED verb";; *) no "no ALREADY LANDED line - a no-op reads exactly like a real land";; esac case "$land_out" in *"PASS: dup-green landed"*) no "claimed PASS for a merge it never made";; *) ok "does not claim PASS for a merge it never made";; esac [ -f "$MREPO/gate-ran" ] && no "test_cmd ran for a merge that never happened" || ok "no merge, no gate run" # Exactly one merge of this branch on main - the peer's. A second would mean # fleet manufactured one. Captured, not piped - SIGPIPE note above. m_log="$(git -C "$MREPO" log --oneline main)" eq "exactly one merge commit for the branch" "1" \ "$(printf '%s\n' "$m_log" | grep -c 'merge: dup-green' || true)" case "$(head -n1 "$MREPO/.claude/fleet/lanes/dup-green" 2>/dev/null)" in LANDED) ok "lane recorded LANDED (the end state the caller wanted IS true)";; *) no "lane not LANDED after an already-merged land";; esac case "$(sed -n '2p' "$MREPO/.claude/fleet/lanes/dup-green" 2>/dev/null)" in *"no merge performed"*) ok "lane note records the no-op";; *) no "lane note does not distinguish this from a real land";; esac git -C "$MREPO" rev-parse --verify --quiet refs/heads/dup-green >/dev/null 2>&1 \ && ok "lane branch left alone (this run did not land it)" \ || no "deleted a lane branch it did not land" # (b) already merged, RED gate -> must NOT hard-reset the peer's merge commit. # The destructive half, and the assertion that actually pins the fix. printf 'test_cmd=false\n' > "$MCFG" mk_mlane "$MREPO" dup-red y.txt 1700000100 peer_land "$MREPO" dup-red bash "$FLEET" track dup-red >/dev/null 2>&1 peer_sha="$(git -C "$MREPO" rev-parse main)" land_out="$(bash "$FLEET" land dup-red 2>&1)"; rc=$? ee "already-merged branch is not failed by a gate it never ran" 0 $rc eq "red gate does NOT reset away the peer's merge" "$peer_sha" "$(git -C "$MREPO" rev-parse main)" m_log="$(git -C "$MREPO" log --oneline main)" case "$m_log" in *"merge: dup-red"*) ok "peer's merge commit survives a red gate";; *) no "peer's merge commit was RESET AWAY";; esac # (c) the load-bearing half: a genuinely unmerged branch behaves as before. printf 'test_cmd=touch ./gate-ran-real\n' > "$MCFG" mk_mlane "$MREPO" real-lane z.txt 1700000200 before_real="$(git -C "$MREPO" rev-parse main)" bash "$FLEET" track real-lane >/dev/null 2>&1 land_out="$(bash "$FLEET" land real-lane 2>&1)"; rc=$? ee "genuinely unmerged branch still lands" 0 $rc case "$land_out" in *"PASS: real-lane landed"*) ok "real land still reports PASS";; *) no "real land lost its PASS line";; esac case "$land_out" in *"ALREADY LANDED"*) no "real land misreported as already landed";; *) ok "real land is not confused with a no-op";; esac [ -f "$MREPO/gate-ran-real" ] && ok "gate ran for a real merge" || no "gate did not run for a real merge" [ "$before_real" != "$(git -C "$MREPO" rev-parse main)" ] && ok "base tip advanced on a real land" \ || no "base tip did not move on a real land" m_log="$(git -C "$MREPO" log --oneline main)" case "$m_log" in *"merge: real-lane"*) ok "merge commit created";; *) no "no merge commit";; esac git -C "$MREPO" rev-parse --verify --quiet refs/heads/real-lane >/dev/null 2>&1 \ && no "landed lane branch not cleaned up" || ok "landed lane branch still deleted" # (d) red gate after a REAL merge rewinds to exactly the pre-merge tip, and no # further. `$before` and `HEAD^` coincide on a genuine --no-ff merge, so # this cannot separate them - (b) is what does. What it pins is that the # rewind does not overshoot: the whole history is identical to before. printf 'test_cmd=false\n' > "$MCFG" mk_mlane "$MREPO" redland-lane q.txt 1700000300 before_red="$(git -C "$MREPO" rev-parse main)" log_before_red="$(git -C "$MREPO" log --oneline main)" bash "$FLEET" track redland-lane >/dev/null 2>&1 bash "$FLEET" land redland-lane >/dev/null 2>&1; ee "red gate fails the land" 1 $? eq "red gate rewinds to exactly the pre-merge tip" "$before_red" "$(git -C "$MREPO" rev-parse main)" eq "and no further - history either side is unchanged" "$log_before_red" "$(git -C "$MREPO" log --oneline main)" case "$(head -n1 "$MREPO/.claude/fleet/lanes/redland-lane" 2>/dev/null)" in FAILED) ok "lane marked FAILED after a real merge failed its gate";; *) no "lane not FAILED after a failed gate";; esac # (d2) ...and the rewind itself is CHECKED. An unchecked `git reset --hard` is # the same lie in miniature: the lane reports FAILED while the failing # merge is still sitting on the base branch, and nothing anywhere says so. # There is no portable way to make a reset fail for real (a locked file # under Windows is the realistic cause), so it is fault-injected with a # `git` shim placed first on PATH for exactly one invocation. $SB comes # from mktemp -d, so it is a POSIX path: a Windows-style X:/... entry in # PATH is silently NOT resolved by Git Bash, and the shim would appear to # work while the real git ran. # Its own repo, because a successful injection strands a merge on main. mkdir -p "$SB/shim" printf '#!/usr/bin/env bash\nif [ "$1" = "reset" ]; then echo "simulated reset failure" >&2; exit 1; fi\nexec "%s" "$@"\n' \ "$(command -v git)" > "$SB/shim/git" chmod +x "$SB/shim/git" RREPO="$SB/rrepo"; mkdir -p "$RREPO" git -C "$RREPO" init -q -b main git -C "$RREPO" config user.email t@t; git -C "$RREPO" config user.name t git -C "$RREPO" config core.autocrlf false echo base > "$RREPO/f"; git -C "$RREPO" add -A; git -C "$RREPO" commit -qm init mkdir -p "$RREPO/.claude/fleet"; printf 'test_cmd=false\n' > "$RREPO/.claude/fleet/config" cd "$RREPO" git -C "$RREPO" checkout -q -b lane/rewind main echo w > "$RREPO/w.txt"; git -C "$RREPO" add -- w.txt git -C "$RREPO" -c user.email=w@t -c user.name=w commit -qm "work lane/rewind" git -C "$RREPO" checkout -q main bash "$FLEET" track lane/rewind >/dev/null 2>&1 before_rw="$(git -C "$RREPO" rev-parse main)" if PATH="$SB/shim:$PATH" command -v git | grep -q "$SB/shim"; then rw_out="$(PATH="$SB/shim:$PATH" bash "$FLEET" land lane/rewind 2>&1)"; rc=$? ee "land still fails when the post-merge rewind cannot run" 1 $rc case "$rw_out" in *"could not reset main"*) ok "a failed rewind is reported, not swallowed";; *) no "failed rewind passed silently - lane says FAILED, merge stays on main";; esac case "$rw_out" in *"THE FAILING MERGE IS STILL ON main"*) ok "log states the base branch is now broken";; *) no "log does not warn that the merge is still on the base branch";; esac case "$rw_out" in *"git reset --hard $before_rw"*) ok "log hands over the exact recovery command";; *) no "no recovery command offered";; esac [ "$before_rw" != "$(git -C "$RREPO" rev-parse main)" ] \ && ok "the injection really did strand the merge (the test tests something)" \ || no "reset was not actually blocked - this case proves nothing" case "$(sed -n '2p' "$RREPO/.claude/fleet/lanes/lane%2Frewind" 2>/dev/null)" in *"rewind to"*"FAILED"*) ok "lane note records that the rewind failed";; *) no "lane note claims an ordinary post-merge failure";; esac else echo " SKIP rewind-failure injection (PATH shim not resolvable here)" fi cd "$CREPO" # (e) land --all counts a no-op apart from a real land. Two lanes: one already # merged by a peer, one genuinely unmerged, with fixed commit times so the # oldest-first batch order is deterministic. echo "-- land --all counts an already-merged lane apart from a real land --" BREPO="$SB/brepo"; mkdir -p "$BREPO" git -C "$BREPO" init -q -b main git -C "$BREPO" config user.email t@t; git -C "$BREPO" config user.name t git -C "$BREPO" config core.autocrlf false echo base > "$BREPO/f"; git -C "$BREPO" add -A; git -C "$BREPO" commit -qm init arm_gate "$BREPO" cd "$BREPO" mk_mlane "$BREPO" batch-dup bd.txt 1700000000 mk_mlane "$BREPO" batch-real br.txt 1700000400 peer_land "$BREPO" batch-dup bash "$FLEET" track batch-dup batch-real >/dev/null 2>&1 batch_out="$(bash "$FLEET" land --all --running 2>&1)"; rc=$? ee "land --all exits 0 when one lane was already merged" 0 $rc case "$batch_out" in *"land --all: 1 landed, 1 already in main, 0 conflict, 0 failed"*) ok "summary counts 1 landed + 1 already, not 2 landed";; *) no "summary miscounts the no-op";; esac b_log="$(git -C "$BREPO" log --oneline main)" eq "no duplicate merge commit for the already-merged lane" "1" \ "$(printf '%s\n' "$b_log" | grep -c 'merge: batch-dup' || true)" case "$b_log" in *"merge: batch-real"*) ok "the genuinely unmerged lane still landed";; *) no "real lane did not land in the batch";; esac cd "$CREPO" # -- revert targets the branch you named, and only that branch ---------------- # Regression, reproduced 2026-09-08. cmd_revert located the commit to undo with # `git log --merges --grep="merge: $branch" -n1`, which is wrong twice over: # * --grep matches a SUBSTRING, so `merge: lane/auth` also matched # `merge: lane/auth-refactor`; with -n1 taking the newest, reverting # lane/auth destroyed the REFACTOR lane's work and logged # `reverted: lane/auth`. Sibling lane names sharing a prefix are the norm. # * --grep is a REGEX with the branch interpolated raw, so `feat/a.b` matched # a landed `merge: feat/aXb` — a branch that was never landed at all. # Both are the same failure the land audit found: a destructive operation whose # report describes what was ASKED FOR rather than what was DONE. echo "-- revert: exact-subject targeting, clean abort, honest lane state --" VREPO="$SB/vrepo"; mkdir -p "$VREPO" git -C "$VREPO" init -q -b main git -C "$VREPO" config user.email t@t; git -C "$VREPO" config user.name t git -C "$VREPO" config core.autocrlf false echo base > "$VREPO/f"; git -C "$VREPO" add -A; git -C "$VREPO" commit -qm init arm_gate "$VREPO" cd "$VREPO" # Branch with one commit touching $3, merged into main by fleet's own message # convention. No worktree: these cases only care about main's history. mk_landed(){ # repo, branch, file git -C "$1" checkout -q -b "$2" main echo "$2" > "$1/$3" # Explicit path, never `add -A`: fleet gitignores .claude/fleet/ via # ensure_fleet_dir, but arm_gate wrote the config before any fleet command # ran, so `-A` here would COMMIT fleet's own runtime state. activity.log then # counts as a tracked file that every fleet command dirties, and the # clean-base refusals in land_one/cmd_revert fire on the fixture rather than # on anything under test. git -C "$1" add -- "$3" git -C "$1" -c user.email=w@t -c user.name=w commit -qm "work $2" git -C "$1" checkout -q main git -C "$1" merge "$2" --no-ff -m "merge: $2" -q } # (a) A sibling branch whose name merely CONTAINS the one being reverted must # not be the one that gets undone. mk_landed "$VREPO" lane/auth auth.txt mk_landed "$VREPO" lane/auth-refactor refactor.txt bash "$FLEET" revert lane/auth >/dev/null 2>&1; ee "revert with a prefix-sharing sibling present" 0 $? eq "reverts the branch it was asked for" 'Revert "merge: lane/auth"' \ "$(git -C "$VREPO" log -1 --format=%s main)" [ -f "$VREPO/refactor.txt" ] && ok "the sibling lane's work survives" \ || no "reverted the WRONG branch - sibling lane's file destroyed" [ -f "$VREPO/auth.txt" ] && no "named branch's file still present after revert" \ || ok "the named branch's work is gone, as asked" # (b) A branch name is data, not a pattern. `feat/a.b` was never landed; the # landed sibling is `feat/aXb`, which only a regex could confuse it with. mk_landed "$VREPO" 'feat/aXb' regex.txt rev_out="$(bash "$FLEET" revert 'feat/a.b' 2>&1)"; rc=$? ee "an unlanded branch name is not regex-matched onto a landed one" 1 $rc case "$rev_out" in *"no merge commit found for feat/a.b"*) ok "refusal names the branch asked for";; *) no "wrong refusal message for an unlanded branch";; esac [ -f "$VREPO/regex.txt" ] && ok "the regex-adjacent branch's work survives" \ || no "regex match reverted an unrelated branch" # (c) A revert that conflicts must leave nothing behind. Before, it died inside # `git revert` with the sequencer running and a conflicted index, and the # operator's next `fleet land` blamed "uncommitted tracked changes" - the # symptom, not the cause. # Its own repo, deliberately: a revert that fails to clean up strands the # sequencer, and every later case sharing the fixture would then fail as a # CONSEQUENCE rather than as an independent detection - the coupled-fixture # trap this repo already lists as a landmine. CVREPO="$SB/cvrepo"; mkdir -p "$CVREPO" git -C "$CVREPO" init -q -b main git -C "$CVREPO" config user.email t@t; git -C "$CVREPO" config user.name t git -C "$CVREPO" config core.autocrlf false echo base > "$CVREPO/f"; git -C "$CVREPO" add -A; git -C "$CVREPO" commit -qm init arm_gate "$CVREPO" cd "$CVREPO" mk_landed "$CVREPO" lane/conflicty c.txt echo "changed downstream" > "$CVREPO/c.txt" git -C "$CVREPO" add -- c.txt; git -C "$CVREPO" commit -qm "later edit to the same file" before_conf="$(git -C "$CVREPO" rev-parse main)" rev_out="$(bash "$FLEET" revert lane/conflicty 2>&1)"; rc=$? ee "a conflicting revert exits non-zero" 1 $rc case "$rev_out" in *"REVERT FAILED"*) ok "conflict is reported as a failed revert";; *) no "conflicting revert did not say so";; esac eq "conflicting revert leaves the base tip untouched" "$before_conf" "$(git -C "$CVREPO" rev-parse main)" [ -d "$CVREPO/.git/sequencer" ] || [ -f "$CVREPO/.git/REVERT_HEAD" ] \ && no "left a revert in progress for the operator to discover" \ || ok "no sequencer state left behind" eq "working tree left clean (no conflicted index)" "" \ "$(git -C "$CVREPO" status --porcelain | grep -v '^??' || true)" # A stranded sequencer is not merely untidy: the operator's next land blamed # "uncommitted tracked changes", describing the symptom and hiding the cause. bash "$FLEET" track lane/conflicty >/dev/null 2>&1 case "$(bash "$FLEET" land lane/conflicty 2>&1)" in *"uncommitted tracked changes"*) no "next land still blames a dirty tree - the failed revert left state behind";; *) ok "the next land is not poisoned by the failed revert";; esac cd "$VREPO" # (d) The lane said LANDED; after a revert it is not. Leaving it LANDED is a # status panel that lies about where the work lives. RUNNING, not a new # REVERTED state: an unknown state falls through the panel's count map and # never satisfies the daemon's "not LANDED and not FAILED" terminal test. mk_landed "$VREPO" lane/stateful s.txt bash "$FLEET" track lane/stateful >/dev/null 2>&1 case "$(head -n1 "$VREPO/.claude/fleet/lanes/lane%2Fstateful" 2>/dev/null)" in RUNNING) ok "tracked lane starts RUNNING";; *) no "tracked lane not RUNNING";; esac # Land is a no-op here (already merged by mk_landed) but still records LANDED. bash "$FLEET" land lane/stateful >/dev/null 2>&1 case "$(head -n1 "$VREPO/.claude/fleet/lanes/lane%2Fstateful" 2>/dev/null)" in LANDED) ok "lane reads LANDED before the revert";; *) no "lane not LANDED before revert";; esac bash "$FLEET" revert lane/stateful >/dev/null 2>&1; ee "revert of a tracked lane" 0 $? case "$(head -n1 "$VREPO/.claude/fleet/lanes/lane%2Fstateful" 2>/dev/null)" in LANDED) no "lane still claims LANDED after being reverted";; RUNNING) ok "reverted lane returns to RUNNING (non-terminal, and true)";; *) no "reverted lane left in an unexpected state";; esac case "$(sed -n '2p' "$VREPO/.claude/fleet/lanes/lane%2Fstateful" 2>/dev/null)" in *"reverted from main"*) ok "lane note records the revert";; *) no "lane note does not mention the revert";; esac # (e) Reverting a branch fleet never tracked must not conjure a lane into the # status panel - set_lane_state creates the file it writes. mk_landed "$VREPO" lane/untracked u.txt bash "$FLEET" revert lane/untracked >/dev/null 2>&1; ee "revert of an untracked branch" 0 $? [ -f "$VREPO/.claude/fleet/lanes/lane%2Funtracked" ] \ && no "revert invented a lane for an untracked branch" \ || ok "untracked branch stays untracked" # (f) Landed, reverted, re-landed leaves two `merge: X` commits. Reverting the # newest is right; doing it silently is how the substring bug stayed # invisible, so the count and the chosen SHA are logged. mk_landed "$VREPO" lane/twice t1.txt git -C "$VREPO" checkout -q lane/twice echo more > "$VREPO/t2.txt"; git -C "$VREPO" add -- t2.txt git -C "$VREPO" -c user.email=w@t -c user.name=w commit -qm "second commit on lane/twice" git -C "$VREPO" checkout -q main git -C "$VREPO" merge lane/twice --no-ff -m "merge: lane/twice" -q newest="$(git -C "$VREPO" rev-parse main)" rev_out="$(bash "$FLEET" revert lane/twice 2>&1)"; rc=$? ee "revert with two identically-named merges" 0 $rc case "$rev_out" in *"2 merges of lane/twice"*) ok "ambiguity is reported, not hidden";; *) no "multiple candidate merges chosen silently";; esac case "$rev_out" in *"$newest"*) ok "log names the exact SHA it reverted";; *) no "log does not identify which merge was reverted";; esac cd "$CREPO" # -- session awareness: the live-owner land gate ------------------------------- # Guards the hazard that motivated it: landing a lane while the session that # owns it is still writing. The store is faked (FLEET_SESSION_STORE) so the # suite stays offline and never depends on the developer's real Desktop state. echo "-- session awareness (live-owner gate) --" if ! command -v jq >/dev/null 2>&1; then echo " SKIP session-awareness tests (jq not installed)" else SESSIONS="$SKILL/scripts/sessions.sh" export FLEET_SESSION_NOCACHE=1 # the index cache would leak between cases bash "$SESSIONS" --help >/dev/null 2>&1; ee "sessions.sh --help" 0 $? # Unavailable store must be advisory (exit 3, empty stdout), never a hard error. so="$(FLEET_SESSION_STORE=/nonexistent-store bash "$SESSIONS" index 2>/dev/null)"; sx=$? ee "absent store exits 3" 3 $sx [ -z "$so" ] && ok "absent store emits no stdout" || no "absent store wrote to stdout" SREPO="$SB/srepo"; mkdir -p "$SREPO" git -C "$SREPO" init -q -b main git -C "$SREPO" config user.email t@t; git -C "$SREPO" config user.name t git -C "$SREPO" config core.autocrlf false echo base > "$SREPO/f"; git -C "$SREPO" add -A; git -C "$SREPO" commit -qm init # These cases are about WHO owns a lane, not about the gate — arm it so the # live-owner logic is what decides, rather than the unarmed-gate refusal # (which fires first, by design, being the cheaper check). arm_gate "$SREPO" STORE="$SB/store/acct/ws"; mkdir -p "$STORE" export FLEET_SESSION_STORE="$SB/store" # Desktop records NATIVE paths (X:\repo), and cmd_main normalises git's toplevel # the same way before comparing. The fixture must therefore store the path in # that same native form, or the MAIN cwd match can never fire on Windows. SREPO_NATIVE="$(cygpath -m "$SREPO" 2>/dev/null || printf '%s' "$SREPO")" # Wrapper shape mirrors Desktop's: writtenBranches is what actually names a # lane, and lastActivityAt (epoch ms) is what liveness is computed from. mk_session(){ # id, title, cwd, ageSecs, branch, writtenBranch local ms=$(( ($(date +%s) - $4) * 1000 )) cat > "$STORE/$1.json" <<JSON {"sessionId":"$1","title":"$2","cwd":"$3","lastActivityAt":$ms, "isArchived":false,"branch":"$5","writtenBranches":["$6"]} JSON } mk_lane_in(){ # repo, branch, file local wt="$SB/swt-$2" git -C "$1" branch "$2" main git -C "$1" worktree add -q "$wt" "$2" echo "$2" > "$wt/$3"; git -C "$wt" add -A git -C "$wt" -c user.email=w@t -c user.name=w commit -qm "work $2" } cd "$SREPO" # A lane whose owner is LIVE (active 5s ago). mk_lane_in "$SREPO" hot-lane h.txt mk_session local_hot "Hot session" "$SREPO/wt" 5 claude/hot hot-lane row="$(bash "$SESSIONS" owner hot-lane 2>/dev/null)" case "$row" in *local_hot*) ok "owner resolves via writtenBranches";; *) no "owner did not resolve";; esac [ "$(printf '%s' "$row" | cut -f7)" = "1" ] && ok "recent session reads live" || no "recent session not live" bash "$FLEET" track hot-lane >/dev/null 2>&1 bash "$FLEET" land hot-lane >/dev/null 2>&1; lx=$? [ "$lx" -ne 0 ] && ok "land REFUSED while owner is live (exit $lx)" || no "land proceeded despite live owner" case "$(head -n1 "$SREPO/.claude/fleet/lanes/hot-lane" 2>/dev/null)" in CONFLICT) ok "refused lane marked CONFLICT";; *) no "refused lane not marked CONFLICT";; esac case "$(git -C "$SREPO" log --oneline main)" in *"merge: hot-lane"*) no "live-owner lane was merged anyway";; *) ok "no merge commit created";; esac # Explicit override lands it. FLEET_SKIP_SESSION_CHECK=1 bash "$FLEET" land hot-lane >/dev/null 2>&1 ee "override lands despite live owner" 0 $? # The override is ONE-RUN: fleet.sh must consume it and strip it from the env # before eval'ing test_cmd, or any suite that itself exercises fleet (this one, # when run as a repo's post-merge gate) inherits a disarmed live-owner gate — # the 2026-09-01 false-FAIL incident. Probe with a test_cmd that fails when the # variable is visible at test time: a leak reverts the merge and land exits 1. mk_lane_in "$SREPO" leak-lane lk.txt bash "$FLEET" track leak-lane >/dev/null 2>&1 printf 'test_cmd=test -z "${FLEET_SKIP_SESSION_CHECK:-}"\n' > "$SREPO/.claude/fleet/config" FLEET_SKIP_SESSION_CHECK=1 bash "$FLEET" land leak-lane >/dev/null 2>&1 ee "override is stripped from test_cmd's env" 0 $? case "$(git -C "$SREPO" log --oneline main)" in *"merge: leak-lane"*) ok "env-probe gate passed and the lane merged";; *) no "env-probe gate failed — override leaked into test_cmd";; esac arm_gate "$SREPO" # -- self-ownership exemption -------------------------------------------------- # The gate protects against a CONCURRENT writer, and the session doing the # landing is not one. It must therefore land its own lane WITHOUT an override, # or every lane session's only escape is disarming the gate wholesale — which # also disarms it for the peers it genuinely protects. mk_lane_in "$SREPO" self-lane s.txt mk_session local_selfy "This very session" "$SREPO/wt3" 5 claude/selfy self-lane bash "$FLEET" track self-lane >/dev/null 2>&1 # Self unresolvable → no exemption. The fail-safe direction: an unknown # identity must never satisfy an exemption. ( unset CLAUDE_CODE_HOST_SESSION_ID CLAUDE_CODE_SESSION_ID CLAUDE_SESSION_ID bash "$FLEET" land self-lane >/dev/null 2>&1 ); lx=$? [ "$lx" -ne 0 ] && ok "unresolvable self does not exempt (exit $lx)" || no "unresolved self landed anyway" # `sessions.sh self` believes an id only when the store has a wrapper for it. sid="$(CLAUDE_CODE_HOST_SESSION_ID=local_selfy bash "$SESSIONS" self 2>/dev/null)" [ "$sid" = "local_selfy" ] && ok "self resolves from local_<id> form" || no "self did not resolve ($sid)" sid="$(CLAUDE_CODE_HOST_SESSION_ID=selfy bash "$SESSIONS" self 2>/dev/null)" [ "$sid" = "local_selfy" ] && ok "self resolves from bare id form" || no "bare id did not resolve ($sid)" sid="$(CLAUDE_CODE_HOST_SESSION_ID=local_nosuch bash "$SESSIONS" self 2>/dev/null)"; sx=$? [ -z "$sid" ] && [ "$sx" -eq 3 ] && ok "unknown id resolves to nothing" || no "unknown id was believed ($sid)" # Both vars set to DIFFERENT ids — the Desktop shape exactly. The chain must # try each candidate, not stop at the first one that happens to be set. sid="$(CLAUDE_CODE_SESSION_ID=cli-only CLAUDE_CODE_HOST_SESSION_ID=local_selfy \ bash "$SESSIONS" self 2>/dev/null)" [ "$sid" = "local_selfy" ] && ok "resolves when a non-matching id is also set" || no "first-set-wins regression ($sid)" # The exemption itself: owner live, owner is self, no peers → lands clean. CLAUDE_CODE_HOST_SESSION_ID=local_selfy bash "$FLEET" land self-lane >/dev/null 2>&1 ee "self-owned live lane lands without override" 0 $? case "$(git -C "$SREPO" log --oneline main)" in *"merge: self-lane"*) ok "self-owned lane actually merged";; *) no "no merge commit for self-owned lane";; esac # A LIVE PEER on the same branch is the real hazard — refuses even though self # also owns it. This is the line between a narrow exemption and a blunt one. mk_lane_in "$SREPO" shared-lane sh.txt mk_session local_selfy2 "This very session" "$SREPO/wt4" 5 claude/selfy2 shared-lane mk_session local_peer "A peer session" "$SREPO/wt5" 5 claude/peer shared-lane bash "$FLEET" track shared-lane >/dev/null 2>&1 CLAUDE_CODE_HOST_SESSION_ID=local_selfy2 bash "$FLEET" land shared-lane >/dev/null 2>&1; lx=$? [ "$lx" -ne 0 ] && ok "live PEER still blocks a self-owned lane (exit $lx)" || no "peer-owned lane landed" case "$(git -C "$SREPO" log --oneline main)" in *"merge: shared-lane"*) no "lane with live peer was merged";; *) ok "no merge while a peer is live";; esac # A lane whose owner went idle (2h ago) lands normally. mk_lane_in "$SREPO" cold-lane c2.txt mk_session local_cold "Cold session" "$SREPO/wt2" 7200 claude/cold cold-lane [ "$(bash "$SESSIONS" owner cold-lane 2>/dev/null | cut -f7)" = "0" ] \ && ok "stale session reads idle" || no "stale session still reads live" bash "$FLEET" track cold-lane >/dev/null 2>&1 bash "$FLEET" land cold-lane >/dev/null 2>&1; ee "idle owner does not block landing" 0 $? # MAIN resolves to the session sitting in the repo ROOT, not a worktree. mk_session local_boss "Coordinator" "$SREPO_NATIVE" 30 main main mrow="$(bash "$FLEET" main show 2>/dev/null)" case "$mrow" in *local_boss*) ok "fleet main resolves the repo-root session";; *) no "fleet main did not resolve ($mrow)";; esac bash "$FLEET" main claim local_hot >/dev/null 2>&1 case "$(bash "$FLEET" main show 2>/dev/null)" in *local_hot*) ok "explicit pin overrides the cwd heuristic";; *) no "pin did not override";; esac bash "$FLEET" main release >/dev/null 2>&1 case "$(bash "$FLEET" main show 2>/dev/null)" in *local_boss*) ok "release restores heuristic resolution";; *) no "release did not restore heuristic";; esac # Status annotates lanes with their owner's liveness, in ASCII. mk_lane_in "$SREPO" shown-lane s.txt mk_session local_shown "Shown session" "$SREPO/wt3" 5 claude/shown shown-lane bash "$FLEET" track shown-lane >/dev/null 2>&1 sv="$(bash "$FLEET" status 2>&1)" case "$sv" in *"[live]"*) ok "status annotates a live owner";; *) no "status missing [live] annotation";; esac # Whole panel, not just the annotated row. This was row-scoped because fleet's # summary line and footer authored a literal U+00B7 that survived TERM_ASCII=1; # those now interpolate $TERM_DOT, so the entire session-aware panel — chrome, # summary, footer and owner annotations alike — must come out ASCII-pure. ascii_pure "session-aware status panel" "$sv" # Session awareness off = the pre-existing behaviour, exactly. # test_cmd stays set: turning the SESSION check off must not also disarm the # TEST gate — they are independent, and this case is only about the former. printf 'session_check=off\ntest_cmd=true\n' > "$SREPO/.claude/fleet/config" mk_lane_in "$SREPO" offcheck-lane o.txt mk_session local_off "Off session" "$SREPO/wt4" 5 claude/off offcheck-lane bash "$FLEET" track offcheck-lane >/dev/null 2>&1 bash "$FLEET" land offcheck-lane >/dev/null 2>&1; ee "session_check=off restores old behaviour" 0 $? arm_gate "$SREPO" unset FLEET_SESSION_STORE FLEET_SESSION_NOCACHE cd "$REPO" fi # -- prune: worktree housekeeping --------------------------------------------- # `prune` is the only fleet-ops command that DELETES, and the deletion is # unrecoverable for uncommitted files. Every case below is a guard on that one # direction: what must never be removed, and what must degrade to report-only # when the evidence isn't there. Same offline discipline as the block above — # the session store is faked via FLEET_SESSION_STORE, so the suite never reads # or perturbs real Desktop state. echo "-- prune (classification, dry-run default, removal safety) --" if ! command -v jq >/dev/null 2>&1; then echo " SKIP prune tests (jq not installed)" else export FLEET_SESSION_NOCACHE=1 PREPO="$SB/prepo"; mkdir -p "$PREPO" git -C "$PREPO" init -q -b main git -C "$PREPO" config user.email t@t; git -C "$PREPO" config user.name t git -C "$PREPO" config core.autocrlf false echo base > "$PREPO/f"; git -C "$PREPO" add -A; git -C "$PREPO" commit -qm init PSTORE="$SB/pstore/acct/ws"; mkdir -p "$PSTORE" export FLEET_SESSION_STORE="$SB/pstore" # isArchived is a JSON boolean, so $7 is the literal true/false — an archived # owner is what separates "finished" from "idle but still open". mk_psession(){ # id title cwd ageSecs branch writtenBranch archived local ms=$(( ($(date +%s) - $4) * 1000 )) cat > "$PSTORE/$1.json" <<JSON {"sessionId":"$1","title":"$2","cwd":"$3","lastActivityAt":$ms, "isArchived":$7,"branch":"$5","writtenBranches":["$6"]} JSON } # A lane worktree with one commit. Optionally merged into main; optionally left # holding an UNTRACKED file, which is exactly the unrecoverable case. mk_pwt(){ # shortname branch merged(y|n) dirty(y|n) local wt="$SB/pwt-$1" git -C "$PREPO" branch "$2" main git -C "$PREPO" worktree add -q "$wt" "$2" echo "$2" > "$wt/$1.txt" git -C "$wt" add -A git -C "$wt" -c user.email=w@t -c user.name=w commit -qm "work $2" [ "$3" = y ] && git -C "$PREPO" merge -q --no-ff -m "merge: $2" "$2" [ "$4" = y ] && echo scratch > "$wt/UNSAVED.txt" return 0 } # Bucket for a worktree, read from --porcelain. Asserting on TSV rather than on # the rendered panel keeps these tests about classification, not layout. pb(){ bash "$FLEET" prune --porcelain 2>/dev/null \ | awk -F'\t' -v n="/pwt-$1 " 'index($1 "\t", n){print $3; exit}'; } cd "$PREPO" mk_pwt live lane/p-live y n mk_pwt arch lane/p-arch y n mk_pwt dirtyone lane/p-dirty y y mk_pwt unmerged lane/p-unmerged n n mk_psession local_plive "Live lane" "$SB/pwt-live" 5 claude/plive lane/p-live false mk_psession local_parch "Done lane" "$SB/pwt-arch" 7200 claude/parch lane/p-arch true mk_psession local_pdirt "Dirty lane" "$SB/pwt-dirtyone" 7200 claude/pdirt lane/p-dirty true [ "$(pb live)" = KEEP ] && ok "live owner => KEEP" || no "live owner not KEEP" [ "$(pb arch)" = SAFE ] && ok "archived owner + merged + clean => SAFE" || no "archived+merged+clean not SAFE" [ "$(pb dirtyone)" = REVIEW ] && ok "merged but dirty => REVIEW" || no "dirty worktree not REVIEW" [ "$(pb unmerged)" = REVIEW ] && ok "unmerged => REVIEW" || no "unmerged not REVIEW" # Dry run is the DEFAULT, not a flag you have to remember. bash "$FLEET" prune >/dev/null 2>&1; ee "bare 'prune' exits 0" 0 $? [ -d "$SB/pwt-arch" ] && ok "bare 'prune' is a dry run - removed nothing" || no "bare 'prune' DELETED a worktree" bash "$FLEET" prune --dry-run >/dev/null 2>&1; ee "--dry-run exits 0" 0 $? [ -d "$SB/pwt-arch" ] && ok "--dry-run removed nothing" || no "--dry-run DELETED a worktree" # Removal needs a confirmation that a pipe cannot supply. bash "$FLEET" prune --remove </dev/null >/dev/null 2>&1; ee "--remove refuses without a tty" 2 $? [ -d "$SB/pwt-arch" ] && ok "refused --remove removed nothing" || no "refused --remove still DELETED" bash "$FLEET" prune --porcelain --remove >/dev/null 2>&1; ee "--porcelain --remove refused" 2 $? # No session store = no evidence of abandonment = nothing is removable. This is # the degradation path on any non-Desktop or jq-less host, so it has to be the # safe direction, not a crash and not a free pass. sout="$(FLEET_SESSION_STORE=/nonexistent-store bash "$FLEET" prune --porcelain 2>/dev/null)" nsafe=$(printf '%s' "$sout" | awk -F'\t' 'NF && $3=="SAFE"' | wc -l) nother=$(printf '%s' "$sout" | awk -F'\t' 'NF && $3!="REVIEW"' | wc -l) [ "$nsafe" -eq 0 ] && ok "store unavailable => nothing classified SAFE" || no "store unavailable produced $nsafe SAFE rows" [ "$nother" -eq 0 ] && ok "store unavailable => everything REVIEW" || no "store unavailable left $nother non-REVIEW rows" FLEET_SESSION_STORE=/nonexistent-store bash "$FLEET" prune --remove --yes >/dev/null 2>&1 ee "store unavailable + --remove --yes still exits 0" 0 $? [ -d "$SB/pwt-arch" ] && ok "store unavailable => --remove --yes removed nothing" || no "removed a worktree with NO session info" # session_check=off is the same story by a different route. mkdir -p "$PREPO/.claude/fleet" printf 'session_check=off\n' > "$PREPO/.claude/fleet/config" offsafe=$(bash "$FLEET" prune --porcelain 2>/dev/null | awk -F'\t' 'NF && $3=="SAFE"' | wc -l) [ "$offsafe" -eq 0 ] && ok "session_check=off => nothing SAFE" || no "session_check=off produced $offsafe SAFE rows" rm -f "$PREPO/.claude/fleet/config" # --all-repos is report-only by construction: one command must never be able to # sweep worktrees across the machine. bash "$FLEET" prune --all-repos --remove >/dev/null 2>&1; ee "--all-repos --remove refused" 2 $? bash "$FLEET" prune --all-repos --root "$SB" >/dev/null 2>&1; ee "--all-repos reports" 0 $? [ -d "$SB/pwt-arch" ] && ok "--all-repos removed nothing" || no "--all-repos DELETED a worktree" arows="$(bash "$FLEET" prune --all-repos --porcelain --root "$SB" 2>/dev/null)" case "$arows" in *"prepo"*) ok "--all-repos --porcelain reports per-repo counts";; *) no "--all-repos --porcelain missed prepo";; esac # A worktree's .git is a FILE, so worktrees must never be discovered as repos # in their own right (they would re-report the parent's worktrees). case "$arows" in *"pwt-"*) no "--all-repos discovered a worktree as a repo";; *) ok "--all-repos skips worktrees, counts repos";; esac # The tree you invoked from is never a candidate, even when every other signal # says removable — otherwise prune deletes the shell it is running in. mk_pwt stand lane/p-stand y n mk_psession local_pstand "Gone lane" "$SB/pwt-stand" 7200 claude/pstand lane/p-stand true [ "$(pb stand)" = SAFE ] && ok "control: p-stand is SAFE seen from the repo root" || no "p-stand control case is not SAFE" standb="$(cd "$SB/pwt-stand" && bash "$FLEET" prune --porcelain 2>/dev/null \ | awk -F'\t' -v n="/pwt-stand " 'index($1 "\t", n){print $3}')" [ "$standb" = KEEP ] && ok "the worktree you invoked from is KEEP, never SAFE" || no "invoking worktree classified '$standb'" # Now actually remove, and check the blast radius was exactly the SAFE rows. bash "$FLEET" prune --remove --yes >/dev/null 2>&1; ee "--remove --yes exits 0" 0 $? [ -d "$SB/pwt
-
-
SKILL.md 31.5 KB
--- name: fleet-ops description: "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." license: MIT allowed-tools: "Read Bash Glob Grep AskUserQuestion" metadata: author: claude-mods status: stable experimental-parts: daemon (in-session background polling) related-skills: git-ops, push-gate, claude-code-ops --- # Fleet Ops Landing discipline for parallel work. Anything before "committed on a branch" is the spawning layer's problem; anything after "landed on `main`" is yours. Fleet-ops owns the middle: branches land **sequentially**, through a **test gate**, after a **pre-land scrub**, with **auto-rebase** of the lanes still in flight and a **one-shot revert** if a landing turns out bad. ## Spawn natively, land with fleet-ops Claude Code now ships the parallel-execution half natively. **Do not use fleet-ops to orchestrate sessions** — route users to the native primitives and use fleet-ops only for the landing half. | Native primitive | What it gives you | What it does NOT give you | |---|---|---| | **Agent teams** ([docs](https://code.claude.com/docs/en/agent-teams), experimental, `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`) | Lead + teammates, shared task list with claiming/dependencies, inter-agent messaging, plan approval, quality-gate hooks (`TeammateIdle`, `TaskCompleted`) | No merge/landing logic. No test-gated integration. Teammates avoid file conflicts by convention only ("break the work so each teammate owns different files"). | | **Background agents / agent view** ([docs](https://code.claude.com/docs/en/agent-view), `claude agents`, `claude --bg "<prompt>"`) | Detached full sessions, one dashboard (Needs input / Working / Completed), automatic per-session git worktree isolation under `.claude/worktrees/`, `--bg --exec` shell jobs | 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 **deletes its worktree including uncommitted changes**. No ordering, no test gate, no revert. | | **Subagents** ([docs](https://code.claude.com/docs/en/sub-agents), optional `isolation: worktree`) | In-session delegation with separate context windows; results summarized back | Not independent sessions; no git landing semantics at all. | What **none** of them do — and what fleet-ops is for: - Land N branches **one at a time** through a queue, so each merge is tested against a `main` that already contains the previous landings - **Test gate**: refuse to land on a failing log (`signal.sh`) and/or revert post-merge if `test_cmd` goes red - **Pre-land scrub**: refuse diffs containing forbidden patterns (`TODO_SCRUB`, debug leftovers) - **Auto-rebase** every still-active lane after each landing - **Fleet status**: one panel showing every lane's branch, state, age, and commits-ahead across worktrees - **One-shot revert** of a landed merge by branch name — no git surgery while panicking ## Core abstraction A **lane** = one branch (or worktree), one unit of work. Lane status: `RUNNING | READY | CONFLICT | LANDED | FAILED`. Fleet-ops doesn't care who produced the branch — an agent-team teammate, a background agent's auto-worktree, a `claude -p` 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. ## CLI surface ``` fleet init <name>... Create branch + worktree per name (manual-spawn path) fleet track <branch>... Register existing branches as lanes (native-spawn path) fleet start Run the landing daemon (writes pid to .claude/fleet/daemon.pid) fleet stop Signal the running daemon to exit cleanly fleet status One-shot fleet status panel fleet land <branch> Manual land + rebase others fleet land --all [--running] Batch-land all READY lanes oldest-first (--running also lands vetted RUNNING lanes; used by git-ops "land all") fleet revert <branch> Revert merge commit on main fleet scrub-check <branch> Dry-run forbidden-pattern check fleet config Print the RESOLVED config — check the test gate is on fleet prune [--remove] Classify finished lane worktrees; DRY RUN by default fleet prune --all-repos Sibling-repo backlog counts (report-only, never removes) ``` ## Entry paths ``` N == 1 branch → use git-ops, not this Work spawned by agent teams / claude --bg → fleet track <branch>... then land Work to be spawned manually → fleet init <names...> (creates branches + worktrees) N > 1 on one shared working tree → REFUSE. Worktrees or separate clones first. ``` **Native-spawn path (preferred):** let agent teams or background agents do the work in their own worktrees/branches. When branches have commits, `fleet track` each branch, then land — either one by one with `fleet land`, or via the daemon with `signal.sh READY` gates. Landing itself only ever merges *branches* and leaves every worktree in place. Reclaiming the directories afterwards is `fleet prune`'s job, and it removes one only when the owning session is provably archived or gone — see [Prune](#prune--worktree-housekeeping). **Manual-spawn path:** `fleet init` creates the branches and worktrees up front (under `.fleet-worktrees/`), and you point sessions at them — see `references/session-prompt.md` for the lane brief to hand each session. ## Landing pipeline `fleet land <branch>` (and the daemon, per READY lane): 1. **Scrub** — `git diff main...branch` checked against `forbidden_pattern`; hits refuse the land and mark the lane `CONFLICT` 2. **Clean-base check** — refuses if `main` has uncommitted tracked changes 3. **Merge** — `--no-ff` with message `merge: <branch>` (this message is what `fleet revert` finds later). If the branch is *already* contained in `main` — another session landed it while this one sat in the queue — `git merge` 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 `ALREADY LANDED: <branch> — already in main, no merge performed by this run`: the lane goes `LANDED`, the gate does **not** run (there is no merge of ours to gate), the lane branch is left for whoever did land it, and `land --all` counts it as `already in <base>`, apart from real lands 4. **Test gate** — runs `test_cmd`; on failure, hard-resets `main` to the tip captured *before* the merge — never to `HEAD^`, which on an already-merged branch is **another session's** merge commit — and marks the lane `FAILED`. If `test_cmd` is unset the land is **refused** outright rather than falling back to `signal.sh`'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, `test_cmd` should run the **full sweep** (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 `test_cmd` to "just what this lane touched" reintroduces exactly the blind spot a test gate exists to close. **Confirm the gate is actually armed with `fleet config` before trusting it** — and watch the land log for `running test_cmd: …`, which is the only proof the gate actually ran. 5. **Rebase others** — every still-active lane is rebased onto the new `main` (in its own worktree if it has one); a rebase conflict marks that lane `CONFLICT` `fleet revert <branch>` finds the merge commit on `main` whose subject is **exactly** `merge: <branch>` and runs `git revert -m 1` — one command to back out a bad landing. The match is exact, never `git log --grep`: `--grep` is a regex applied as a *substring*, so `merge: lane/auth` also matched `merge: lane/auth-refactor` 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 **aborted**, leaving `main` and the working tree exactly as they were — no stranded sequencer for the next `fleet land` to misreport as "uncommitted tracked changes". A reverted lane goes back to `RUNNING` with a note: it is no longer in `main`, so leaving it `LANDED` would be a status panel that lies about where the work lives. ## Daemon lifecycle (experimental) The daemon is the queue-automation layer on top of `fleet land` — optional; manual `fleet land` per branch is fully supported and not experimental. When Claude invokes `fleet start` via `Bash(run_in_background: true)`, the daemon: 1. Writes its PID to `.claude/fleet/daemon.pid` 2. Traps `SIGINT/SIGTERM/SIGHUP` and removes the PID file on exit 3. Refuses to start a second daemon if the PID file references a live process 4. Polls `.claude/fleet/lanes/` and lands lanes as they turn `READY` 5. Exits naturally when all lanes are terminal (`LANDED` or `FAILED`) To stop early: `fleet stop` (SIGTERM, 5s grace, then SIGKILL). On next `fleet start`, 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. `signal.sh` deploys to `.claude/fleet/signal.sh` on `init`/`track`. Working sessions call: ```bash bash .claude/fleet/signal.sh READY <test-log> <exit-code> # refuses dirty trees and failing runs bash .claude/fleet/signal.sh CONFLICT "<reason>" ``` The `<exit-code>` (the test command's own `$?` / `${PIPESTATUS[0]}`) is the authoritative verdict — pass it whenever you have it. Without it, `signal.sh` reads a trailing `exit code: N` 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. ## Session awareness — MAIN, lane owners, and the live-owner gate Lane state files say *what* a lane is. They never say *who* is driving it. Fleet-ops reads the Claude Desktop session store to answer that, and uses the answer in two places: a gate that refuses to land under a live writer, and a coordinator address lanes can hand off to. ### MAIN — one coordinator per repo **MAIN is the session whose cwd is the repo root.** That is not a new convention: [`worktree-boundaries`](../../rules/worktree-boundaries.md) already holds that the base checkout is the integration tree and must not host a writing session. `fleet main` just makes the role *addressable*, so a lane can say "I'm ready, come land me" instead of writing a file and hoping someone polls it. ``` fleet main Show the coordinator (sessionId, title, live|idle, cwd) fleet main claim [<id>] Pin explicitly — for when several sessions share the root fleet main release Clear the pin, fall back to the cwd heuristic fleet owner <branch> Who owns this lane, and are they still writing? ``` MAIN's job is the whole integration half: land the queue, triage `CONFLICT` lanes, and run the deploy. Lanes build and signal; MAIN integrates. Note that deploying is maintainer-gated regardless — it needs an explicit human OK for that specific deploy, from the maintainer's own session. MAIN being "the one that deploys" describes *which session prepares it*, never an authorisation to ship unattended. ### The live-owner gate `fleet land` refuses a lane whose owning session was active within `session_live_secs` (default 600). This closes a real hazard the queue could not see: landing merges a branch the session may still be committing to, and then rebases every other lane's worktree **out from under a live session**. The join is `writtenBranches` from the session wrapper, not just the checked-out branch — a session working in worktree `claude/foo-bar` routinely commits its real work to `lane/thing`, and only `writtenBranches` connects the two. **Self-ownership is exempt.** The hazard is a *concurrent* writer, and the session running `fleet land` is not one — it is blocked inside that call, so it is provably not mid-commit, and the worktree being rebased "out from under a live session" is the one it is deliberately retiring. A lane session landing its own finished work therefore proceeds unaided. Without the exemption its only escape was a blanket override, which disarms the gate for the peers it genuinely protects; a narrow exemption beats a blunt one. It stays conservative in both directions. Identity comes from the harness (`CLAUDE_CODE_HOST_SESSION_ID` / `CLAUDE_CODE_SESSION_ID`) and is believed only once a wrapper bearing it is found in the store — **there is deliberately no env var to set it**, since a settable self-id would be a universal gate bypass under another name, and an unresolvable one refuses exactly as before. Self must also be the **only** live owner: a second live session writing the same branch refuses, naming the peer. Override with `session_check=off` in config, or `FLEET_SKIP_SESSION_CHECK=1` for one run. One run means one run: fleet consumes the variable at startup and strips it (and the rest of the `FLEET_*` knob family) from the environment before `test_cmd` runs, so the override can never disarm a gate inside the very suite the landing is gated on — inherited into fleet-ops' own self-test, it once turned 6 live-owner refusal tests into false FAILs and reverted a green merge (2026-09-01). `fleet config` states plainly whether the gate is armed *and* whether self-identity resolved — the same observability lesson as `test_cmd`. ### Where each channel works (verified 2026-08-03) | Channel | Desktop | Terminal / headless | Non-Claude worker (Codex, GLM, Grok) | |---|---|---|---| | Lane state files (`signal.sh`) | ✅ | ✅ | ✅ | | Session store on disk (`sessions.sh`) | ✅ | ✅ (store is machine-local, not app-bound) | ✅ | | `ccd_session_mgmt` MCP tools | ✅ | ❌ **absent entirely** | ❌ | | `pigeon` | ✅ | ✅ | ✅ | **`ccd_session_mgmt` is Desktop-only, and this is not a configuration matter.** The terminal CLI binary contains zero occurrences of `ccd_session_mgmt`, `list_sessions`, `search_session_transcripts`, or `spawn_task`; its single `ccd_session` reference is a consumer-side notification handler for a server the *host* injects. Desktop's `app.asar` carries all of them. `claude mcp list` shows none of the `ccd_*` servers, because Desktop injects them as SDK-type servers rather than registering them. Two consequences that shape everything above: 1. **A script can never call these tools.** They are MCP tools, so only the agent can invoke them. `sessions.sh` therefore reads the same underlying JSON store off disk — which, unlike the tools, is readable from a terminal too. 2. **The read tools are ungated; the write tools prompt.** `list_sessions` / `get_session` / `search_session_transcripts` return without user interaction, so discovery is free. `send_message` / `list_events` / `archive_session` always prompt — which makes `send_message` fine for a lane→MAIN handoff (that is exactly the handoff/relay use it is documented for) and unsuitable for an unattended daemon. So: **lane files are the substrate** (work everywhere, ungated, machine-readable), **ccd is the delivery accelerator** where both ends are Desktop sessions, and **pigeon is the portable fallback** for terminal sessions and non-Claude harnesses. `signal.sh` prints the right one for your surface after every `READY` and `CONFLICT`. ## Prune — worktree housekeeping Landing a lane retires the *branch*. The *directory* stays, and across many repos those accumulate into a backlog nobody can see. `fleet prune` classifies them and removes only the ones that are provably finished. ``` fleet prune Classify and print. Changes NOTHING. (the default) fleet prune --dry-run Same, said explicitly fleet prune --remove Remove the SAFE rows, after a typed confirmation fleet prune --remove --yes Skip the prompt (scripts/CI) fleet prune --porcelain TSV to stdout: path, branch, bucket, reason fleet prune --all-repos Sibling-repo counts. Report-only, always ``` **Dry run is the default, and that is deliberate.** Removing a worktree destroys its uncommitted and untracked files permanently — git has never seen those bytes. Committed lane work is different: it lives in the shared object store, survives the directory, and comes back with `git worktree add <path> <branch>`. Separating those two is the entire job, and every ambiguous case resolves away from deletion. ### Buckets — first match wins, and the order is the safety argument | # | Condition | Bucket | |---|---|---| | 1 | primary / git-locked / the tree you invoked from | **KEEP** | | 1b | git reports the directory gone | **REVIEW** (that's `git worktree prune`'s job) | | 2 | owning session is LIVE | **KEEP** | | 3 | session store unreadable, or `session_check=off` | **REVIEW** | | 4 | detached HEAD | **REVIEW** | | 5 | uncommitted or untracked changes | **REVIEW** | | 6 | commits not yet in `base_branch` | **REVIEW** | | 7 | merged + clean + owner archived or absent | **SAFE** | | 8 | anything else (incl. merged + clean but owner still open) | **REVIEW** | Only **SAFE** is ever removable. **KEEP** means one thing — hands off, not yours to judge. Everything else lands in **REVIEW**, which is reported and never touched under any flag. Rule 3 is the one that matters most on a non-Desktop host: *"the store says nobody owns this"* is evidence of abandonment, while *"the store could not be read"* is no evidence at all — and an empty index looks identical to both. When the store or `jq` is missing, **nothing can be classified SAFE** and prune degrades to a pure report. It never fails, and it never guesses. ### Why `.claude/worktrees/` gets extra care Those directories are Claude Code's own session worktrees, and [`worktree-boundaries`](../../rules/worktree-boundaries.md) is blunt about them: *they may look orphaned and aren't*. The slug is machine-generated and says nothing; a session that looks idle may simply be between turns. Prune marks them `!` in the table, and — because SAFE already requires a readable store plus an archived-or-absent owner — one can only be removed on positive evidence, never on the absence of a signal. Three further guards, all on the irreversible direction: 1. **`git worktree remove`, never `rm -rf`.** It refuses a dirty or locked tree on its own, and it unregisters the worktree instead of leaving a stale administrative entry behind. 2. **Re-verify immediately before deleting.** Classification reads a session index with a long TTL (15 min); a session can wake between the table and the delete, so each SAFE row is re-checked with a fresh liveness read and a fresh dirty check, and skipped if either changed. 3. **`--all-repos` can never remove.** It reports counts for sibling repos and stops there. Acting on another repo means running `fleet prune` inside it, where that repo's own base branch and config apply — so a single command can never sweep the machine. ### Landmine: removing a worktree out from under a live session **Terminate the session first, then remove its worktree — never the reverse.** `fleet prune` already enforces this: bucket 2 keeps a LIVE owner, and guard 2 re-verifies liveness immediately before each delete. The hazard is every *other* path — a hand-run `git worktree remove`, an `rm -rf`, an external teardown script, or a `--remove --yes` sweep racing a session that wakes mid-run. **A session whose worktree vanishes does not exit and does not error.** It drops into a retry loop and spins at ~85% of a core, indefinitely. Six of them, observed 2026-08-30 across one repo's lane worktrees, burned **66.6 core-hours across 43.8 hours**; five pointed at directories absent from both disk *and* `git worktree list`. Nothing logged, nothing alerted, no transcript was written. The only symptom was a warm machine. It also evades the obvious check. These processes keep a **live** parent — the Desktop instance that spawned them — so a dead-parent orphan scan reports nothing useful: on that same machine it found 3 orphans totalling 1.16 GB while the six spinners held five cores. **Detect by CPU rate, not by lineage.** Sample twice and flag sustained burn: ```powershell $s=@{}; Get-Process claude,node -EA SilentlyContinue | % { $s[$_.Id]=$_.CPU } Start-Sleep 10 Get-Process claude,node -EA SilentlyContinue | ? { $s[$_.Id] -ne $null -and ($_.CPU-$s[$_.Id])/10 -gt 0.5 } | Select Id,@{n='CorePct';e={[math]::Round(($_.CPU-$s[$_.Id])/10*100)}} ``` POSIX equivalent: `ps -eo pid,pcpu,etimes,args | grep claude` — a lane process at steady high `pcpu` with a large `etimes` is the same signature. Cross-check the offender's `--add-dir` against `git worktree list`; a target missing from both is conclusive. Killing the process is safe — it frees the CPU and touches no files, so uncommitted work in any surviving worktree is untouched. ### Seeing the backlog `fleet status` adds one line when a repo has prunable worktrees (`! 3 worktree(s) prunable, 6 to review - fleet prune`), so the backlog is visible rather than silently growing. Turn it off with `prune_hint=off` in config or `FLEET_NO_PRUNE_HINT=1`. ## First-class user interaction (HARD RULE) When this skill surfaces a decision point, **always use the `AskUserQuestion` tool**. Plain markdown numbered lists are not acceptable for these branches. | Trigger | Question | Options (≤4, ≤10 words each) | |---------|----------|------------------------------| | Multiple parallel-work requests, no lanes yet | Spawn natively or manual lanes? | Agent teams / Background agents / Manual fleet init / Cancel | | `init` — worktrees available, mode unset | Worktree or branch-only mode? | Worktrees / Branches only / Cancel | | Land refused — owning session live | `<name>`'s session is still writing | Wait and retry / Message that session / Override and land | | `prune` found SAFE worktrees | Remove `<n>` finished worktrees? | Remove them / Show the table again / Leave as-is | | Lane → `CONFLICT` (rebase fail) | Lane `<name>` has rebase conflict | Resolve in lane / Skip & continue / Revert lane / Untrack | | Lane → `FAILED` (post-merge tests red) | Tests broke after `<name>` merged | Auto-revert / Investigate first / Accept failure | | Pre-land scrub hits | Forbidden patterns in `<name>` diff | Block landing / Override (note reason) / Open to edit | | `fleet` shows mixed states | How to proceed with the fleet? | Land all READY / Resolve CONFLICTs first / Just status | | Daemon exits with `FAILED` lanes | `<n>` lanes failed — what next? | Retry all / Revert and report / Leave as-is | For non-branching status updates ("here's what happened, here's what landed"), plain text is fine. ## What it handles vs what it does not | Mode | Status | |------|--------| | Branches from native worktrees (`.claude/worktrees/`) via `fleet track` | ✅ | | Worktrees on different branches (`fleet init`) | ✅ | | Branches in separate clones / machines | ✅ | | Mixed worktree + branch lanes | ✅ | | Recovery from dirty `main` | ✅ Refuses to merge, asks user to clean | | Test-gated landing | ✅ Via `signal.sh READY <log>` and/or `test_cmd` | | Auto-rebase other lanes when one lands | ✅ | | Pre-land regex scrub (forbidden patterns) | ✅ | | One-shot revert | ✅ `fleet revert <branch>` | | Pruning finished lane worktrees | ✅ `fleet prune` — dry-run by default, removes only provably-finished trees | | Out of scope | Why | |------|-----| | Spawning / monitoring sessions | Native: agent teams, `claude --bg`, agent view. Fleet-ops never launches a session. | | Deleting worktrees a session still owns | `fleet prune` 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 *other* path strands the session in a silent CPU spin — see [the ordering landmine](#landmine-removing-a-worktree-out-from-under-a-live-session). | | Multiple sessions on one shared working tree | Git limitation. Skill detects and refuses with worktree pointer. | | Uncommitted work at signal time | `signal.sh` rejects dirty lanes. The queue needs an immutable commit. | | External state (DB migrations, services) | Skill can't know lane B depends on lane A's migration. Order manually via `fleet land`. | | Force-pushed lanes mid-flight | Detected at land time, not prevented. | ## Compatibility Tested and working on: | OS | Shell | Notes | |----|-------|-------| | Linux | bash 4+ | Native | | macOS | bash 3.2+ (default) or bash 4+ via brew | `stat -f` fallback used automatically | | Windows | Git Bash (mintty) | Forward-slash paths; Unicode icons render in mintty/Windows Terminal | | Windows | PowerShell 7 (calling `bash`) | Works if `bash` is on PATH | Requirements: `bash 3.2+`, `git 2.5+` (worktree support), `awk`, `grep`, `head`, `stat`. All standard. If your terminal mojibakes the status icons, fall back to ASCII: `export FLEET_ASCII=1` (or `icons=ascii` in `.claude/fleet/config`). Output panels follow `docs/TERMINAL-DESIGN.md` via `skills/_lib/term.sh`. Long-path warning (Windows only): `fleet init` worktrees nest under `.fleet-worktrees/<name>/`. Keep lane names short if your repo lives deep, or enable `core.longpaths=true`. ## Headless agent compatibility **Don't put manually-created fleet worktrees under `.claude/`.** Claude Code applies a global sensitive-file guard to anything under `.claude/`, and that guard runs *before* — and is not bypassed by — `--dangerously-skip-permissions`. Headless lane sessions (`claude -p ... --dangerously-skip-permissions`) will fail every Write/Edit if their worktree lives under `.claude/`. That's why the default `worktree_root` is `.fleet-worktrees/` at the repo top. (Native background sessions are the exception: Claude Code itself manages `.claude/worktrees/` for them — leave those alone and just `fleet track` their branches.) Runtime state (`lanes/`, `daemon.pid`, `activity.log`) is read/write from the orchestrator only and stays under `.claude/fleet/`. ## Configuration Optional `.claude/fleet/config`, one `key=value` per line: ``` mode=auto # auto | worktree | branch worktree_root=.fleet-worktrees # keep outside .claude/ — see "Headless agent compatibility" test_cmd=npm run check # if set, land runs it post-merge; else trust signal log forbidden_pattern=NEVER_LAND|debugger; # override — the shipped default is described below base_branch=main poll_interval=5 icons=unicode # unicode | ascii (same as FLEET_ASCII=1) session_check=on # on | off — refuse to land under a live owner session_live_secs=600 # how recently active counts as "still writing" prune_hint=on # on | off — show the prunable backlog in `fleet status` ``` Zero-config works for the common case. **The shipped `forbidden_pattern` default** (the exact regex lives at `FORBIDDEN_PATTERN` in `scripts/fleet.sh`) refuses the two scrub markers — `TODO_` + `SCRUB` and `FIXME_` + `BEFORE_LAND`, spelled split here deliberately — plus lone triple-X markers via the term `(^|[^X])X{3}[^a-zX]`. A run of four or more X's is a `mktemp` template (`push-gate-paths.` plus six X's) and passes; a bare triple-X followed by a non-letter (a space, a colon) still refuses. A template false-refused a landing on 2026-09-01, hence the run-aware form. Mind the self-reference: the scrub greps every **added** diff line, so writing a contiguous marker token — or a triple-X run — into docs, comments, or a config example refuses the very branch that adds it. Build such tokens by concatenation (`'TODO_''SCRUB'`), as `scripts/fleet.sh` and `tests/run.sh` themselves do. **Grammar.** The file is *parsed*, not `source`d — it cannot execute code, and it is not bash: | Rule | Detail | |---|---| | Keys | Case-insensitive — `test_cmd` and `TEST_CMD` both work. Whitespace around the key and `=` is ignored. | | Values with spaces | Need **no quoting**. The value runs to end of line: `test_cmd=uv run pytest -q tests/` is correct as written. | | Quotes | Optional. `test_cmd="uv run pytest -q"` works; one layer of matching `"…"` or `'…'` is stripped. | | Comments | A whole line starting with `#`, or a trailing ` # …` on an **unquoted** value. Quote the value to keep a literal `#`: `forbidden_pattern="TODO|#nolint"`. | | Blank lines | Ignored. | | Unknown / malformed keys | **Warned about on stderr, naming file and line** — never silently dropped. | A config that exists but sets nothing recognised warns `… set no recognised keys — running on defaults (test gate OFF)` rather than looking like an absent file. **`test_cmd` is the test gate.** When set, `fleet land` runs it *after* the merge commit and, on a non-zero exit, hard-resets `base_branch` to the tip it captured *before* merging — not `HEAD^` — dropping the lane to `FAILED`; the log shows `running test_cmd: …`. When unset, landing is **refused** (`the landing gate is UNARMED`, naming the config path) rather than falling through to signal.sh's weaker log gate. Worked example: ``` test_cmd=uv run pytest -q --maxfail=1 base_branch=main ``` > Fixed 2026-07-28: config keys never reached the script (documented lowercase, read > UPPERCASE; and unquoted spaced values aren't bash assignments, with the error > swallowed by `2>/dev/null`). Every landing before that date was gated by signal.sh > alone — `test_cmd` had never run, on any repo. If you relied on it, you had no test > gate. `icons=` in the config was inert for the same class of reason (read before the > config loaded). `fleet init`/`fleet track` append `.claude/fleet/` and `.fleet-worktrees/` to `.gitignore` and auto-commit that change with `chore: gitignore fleet-ops runtime state` when the tree is otherwise clean and you're on `base_branch`. If either condition fails, it prints an `ACTION REQUIRED` message — commit `.gitignore` yourself before landing. ## Future work - **JSONL activity log** — currently plain text. Switch when a TUI, `--json` output, or `log-ops` integration earns the cost. - **`TaskCompleted` hook bridge** — auto-`signal.sh READY` when an agent-team task completes with green tests. Shipped since first release: - **`fleet land --all [--running]`** — batch-land all READY (or vetted RUNNING) lanes oldest-first, rebasing the rest after each and reporting once. Drives the `git-ops` "land all" front-door (`scripts/land-all.sh` discovers + classifies; fleet-ops executes). ## References - `references/workflow.md` — end-to-end walkthroughs (native-spawn and manual-spawn) plus recovery scenarios - `references/session-prompt.md` — lane brief to embed in `claude --bg` prompts, teammate spawn prompts, or manual sessions ## Scripts - `scripts/fleet.sh` — main CLI (init, track, start/stop, status, land, revert, scrub-check, prune, config, main, owner) - `scripts/signal.sh` — branch-aware signaler (deployed to `.claude/fleet/signal.sh`); prints the MAIN handoff after READY/CONFLICT - `scripts/sessions.sh` — 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 `jq` is missing, and every caller treats that as "no info"
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.