Browse Source

feat(skills): add fleet-ops experimental skill (dogfood)

Lean traffic-controller for managing concurrent Claude sessions on
parallel branches or worktrees. Designed from real friction observed
during Axiom hackathon (16 active worktrees, mid-sprint revert,
manual scrub of sprint artifacts pre-publish).

Status: experimental — not in README Recent Updates, no version bump.
Dogfood pass before promoting to a versioned release.

Core abstraction is a "lane": one branch (or worktree), one Claude
session, one logical unit. Status lifecycle: RUNNING → READY →
LANDED|FAILED|CONFLICT. Daemon polls .fleet/lanes/, lands READY
lanes one at a time, auto-rebases other active lanes, refuses on
dirty main or scrub-pattern hits.

CLI: init / start / fleet / land / revert / scrub-check.

Cross-platform: bash 3.2+, GNU+BSD stat fallback, FLEET_ASCII=1
icon fallback for legacy Windows terminals. Tested end-to-end on
Git Bash on Windows (init → 2 lanes → daemon → both lands cleanly).

SKILL.md hard-rule: Claude must use AskUserQuestion (not markdown
lists) for the six explicit decision points the skill surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 month ago
parent
commit
52048b5e63

+ 132 - 0
skills/fleet-ops/SKILL.md

@@ -0,0 +1,132 @@
+---
+name: fleet-ops
+description: "EXPERIMENTAL — manage a fleet of concurrent Claude sessions on parallel branches or worktrees. Landing queue with test gate, fleet status view, pre-land scrub, one-shot revert. Triggers on: multiple Claude sessions, parallel sessions, concurrent agents, 5 sessions, branch queue, landing queue, fleet of sessions, parallel feature work, merge multiple branches, parallel branches."
+license: MIT
+allowed-tools: "Read Bash Glob Grep AskUserQuestion"
+metadata:
+  author: claude-mods
+  status: experimental
+  related-skills: git-ops, push-gate
+---
+
+# Fleet Ops (experimental)
+
+Manage how committed work from isolated lanes lands on `main`. Anything before "committed" or after "landed" is somebody else's problem.
+
+> **Status: experimental.** Dogfooding phase. API may change. Not yet in `README.md` Recent Updates.
+
+## Core abstraction
+
+A **lane** = one branch (or worktree), one Claude session, one logical unit of work. Lane status: `RUNNING | READY | CONFLICT | LANDED | FAILED`.
+
+The skill doesn't care if there are 2 lanes or 20, doesn't care about branch names, doesn't care if you use worktrees or separate clones.
+
+## CLI surface
+
+```
+fleet init <name>...        Create branch + worktree per name
+fleet start                 Run the daemon (Ctrl-C to stop)
+fleet fleet                 One-shot status view
+fleet land <branch>         Manual land + rebase others
+fleet revert <branch>       Revert merge commit on main
+fleet scrub-check <branch>  Dry-run forbidden-pattern check
+```
+
+`signal.sh` deploys to `.fleet/signal.sh` on `init`. Sessions call:
+
+```bash
+bash .fleet/signal.sh READY <test-log>
+bash .fleet/signal.sh CONFLICT "<reason>"
+```
+
+## Decision tree
+
+```
+N == 1                                    → use git-ops, not this
+N > 1, all on shared local working tree   → REFUSE. Use worktrees or separate clones.
+N > 1, worktrees available                → fleet init <names...>
+N > 1, separate clones / remote           → use mode=branch, manual git branch + signal.sh
+```
+
+## 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 — they make the skill feel like a wrapped script instead of a native interaction.
+
+| Trigger | Question | Options (≤4, ≤10 words each) |
+|---------|----------|------------------------------|
+| `init` — worktrees available, mode unset | Worktree or branch-only mode? | Worktrees / Branches only / Cancel |
+| 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. The split matches the global `~/.claude/CLAUDE.md` "Asking Questions" rule.
+
+## What it handles vs what it does not
+
+| Mode | Status |
+|------|--------|
+| Worktrees on different branches | ✅ Primary mode |
+| 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>` |
+| Auto-rebase other lanes when one lands | ✅ |
+| Pre-land regex scrub (forbidden patterns) | ✅ |
+| One-shot revert | ✅ `fleet revert <branch>` |
+
+| Out of scope | Why |
+|------|-----|
+| 5+ sessions on one local working tree | Git limitation. Skill detects and refuses with worktree pointer. |
+| Uncommitted work at signal time | `signal.sh` rejects dirty lanes. Daemon needs an immutable commit. |
+| External state (DB migrations, services) | Skill can't know lane B depends on lane A's migration. Order manually. |
+| 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:
+
+```bash
+export FLEET_ASCII=1
+# or in .fleet/config:
+icons=ascii
+```
+
+Long-path warning (Windows only): worktrees nest under `.fleet/worktrees/<name>/`. If your repo lives deep in the filesystem, lane names should stay short to avoid Windows' 260-char path limit. Enable `core.longpaths=true` in git if you hit it.
+
+## Configuration
+
+Optional `.fleet/config` (key=value, no quotes):
+
+```
+mode=auto                            # auto | worktree | branch
+worktree_root=.fleet/worktrees
+test_cmd=                            # if set, daemon runs this; else trust signal log
+forbidden_pattern=TODO_SCRUB|XXX
+base_branch=main
+poll_interval=5
+```
+
+Zero-config works for the common case.
+
+## References
+
+- `references/session-prompt.md` — copy-paste template for each Claude session
+- `references/workflow.md` — end-to-end walkthrough plus recovery scenarios
+
+## Scripts
+
+- `scripts/fleet.sh` — main CLI
+- `scripts/signal.sh` — branch-aware signaler (deployed to `.fleet/signal.sh` on init)

+ 0 - 0
skills/fleet-ops/assets/.gitkeep


+ 60 - 0
skills/fleet-ops/references/session-prompt.md

@@ -0,0 +1,60 @@
+# Session Prompt Template
+
+Copy-paste this when launching each Claude session. 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:
+  git checkout <branch-name>
+  # If you're in a worktree, you're already on it.
+
+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.
+  - When tests pass and you're ready to land, run:
+      bash .fleet/signal.sh READY <path-to-test-log>
+  - If you hit a conflict, scope creep, or any unresolvable issue, run:
+      bash .fleet/signal.sh CONFLICT "<one-line reason>"
+    then stop and explain.
+  - Do not merge to main yourself. The fleet daemon handles landing.
+
+Begin.
+```
+
+---
+
+## Filling in the fields
+
+| Field | Example |
+|-------|---------|
+| `LANE` | `auth-middleware` (matches the branch name from `fleet init`) |
+| `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 is what `signal.sh READY` reads to verify tests passed.
+
+## Why the scope rule matters
+
+If two lanes silently edit the same file, the daemon's auto-rebase will throw a conflict on the second one. By forcing each session to declare and respect its scope, you catch the overlap at design time, not merge time.
+
+## 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` does crude pass detection — it works fine for these. If your test runner has unusual output, write a small grep-friendly summary line at the end.

+ 125 - 0
skills/fleet-ops/references/workflow.md

@@ -0,0 +1,125 @@
+# Workflow
+
+End-to-end walkthrough plus recovery scenarios. The decision tree and CLI surface live in `SKILL.md` — this doc is the operational manual.
+
+## End-to-end
+
+### 1. Init
+
+```bash
+fleet init auth-mw rate-limiter cache-layer
+```
+
+Creates: a branch per name (off `main`), a worktree at `.fleet/worktrees/<name>/`, a status file at `.fleet/lanes/<name>` (state: `RUNNING`), and deploys `signal.sh` to `.fleet/signal.sh`.
+
+Force branch-only mode: `mode=branch` in `.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
+bash .fleet/signal.sh READY tests/test_auth.log
+```
+
+`signal.sh` will refuse if the lane has uncommitted changes or if the test log shows failures.
+
+### 3. Run the daemon
+
+```bash
+fleet start
+```
+
+Polls `.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 fleet
+```
+
+```
+── Fleet ──────────────────────────────────────────────────────
+       BRANCH                           STATUS     AGE
+────────────────────────────────────────────────────────────────
+  ⏳   auth-mw                          RUNNING    23m
+  ✅   rate-limiter                     READY      1m
+  🚀   cache-layer                      LANDED     8m
+  ⚠️   error-handling                   CONFLICT   12m
+────────────────────────────────────────────────────────────────
+```
+
+### 5. Cleanup
+
+When all lanes are terminal (`LANDED` or `FAILED`), the daemon exits. To tear down:
+
+```bash
+git worktree remove .fleet/worktrees/<name>     # for each worktree lane
+rm -rf .fleet                                    # nuke fleet state
+```
+
+`fleet init` is idempotent — keep `.fleet/` for the next round if you want.
+
+## Recovery
+
+### `CONFLICT` lane (rebase or merge failed)
+
+Pop into that session's terminal. 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 .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 .fleet/signal.sh READY <test-log>
+```
+
+Daemon picks it up on next poll.
+
+### Bad land that snuck through scrub + tests
+
+```bash
+fleet revert <branch>
+```
+
+Finds the merge commit on `main` (by message `merge: <branch>`), runs `git revert -m 1`, logs the action. No git surgery while you're panicking.
+
+## Common patterns
+
+### Five small refactors, no shared scope
+
+Default mode. 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. If batch mode becomes a real need, the next iteration adds `--batch`.

+ 325 - 0
skills/fleet-ops/scripts/fleet.sh

@@ -0,0 +1,325 @@
+#!/usr/bin/env bash
+# fleet-ops — landing queue manager for concurrent Claude sessions
+# Status: experimental
+set -euo pipefail
+
+FLEET_DIR=".fleet"
+LANES_DIR="$FLEET_DIR/lanes"
+LOG="$FLEET_DIR/activity.log"
+CONFIG="$FLEET_DIR/config"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# defaults (overridable via .fleet/config: key=value, no quotes)
+MODE="auto"
+WORKTREE_ROOT=".fleet/worktrees"
+TEST_CMD=""
+FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
+BASE_BRANCH="main"
+POLL_INTERVAL=5
+[[ -f "$CONFIG" ]] && source "$CONFIG" 2>/dev/null || true
+
+# OS-aware icon set: Unicode by default, ASCII fallback for legacy terminals
+# Override: FLEET_ASCII=1 or .fleet/config has icons=ascii
+if [[ "${FLEET_ASCII:-}" == "1" ]] || [[ "${icons:-}" == "ascii" ]]; then
+  ICON_RUNNING="[.]"; ICON_READY="[+]"; ICON_LANDED="[*]"
+  ICON_FAILED="[X]";  ICON_CONFLICT="[!]"; ICON_UNKNOWN="[?]"
+else
+  ICON_RUNNING="⏳";  ICON_READY="✅";  ICON_LANDED="🚀"
+  ICON_FAILED="❌";   ICON_CONFLICT="⚠️ "; ICON_UNKNOWN="? "
+fi
+
+# 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
+}
+
+log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG" >&2; }
+
+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
+  # Auto-ignore .fleet/ in git so it doesn't show as "dirty" or get committed
+  if [[ -d .git ]] || git rev-parse --git-dir >/dev/null 2>&1; then
+    if [[ ! -f .gitignore ]] || ! grep -qxF '.fleet/' .gitignore 2>/dev/null; then
+      echo '.fleet/' >> .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() { [[ -f "$LANES_DIR/$1" ]] && head -n1 "$LANES_DIR/$1" || echo "MISSING"; }
+set_lane_state() {
+  local l=$1 s=$2
+  shift 2
+  if [[ $# -gt 0 ]]; then
+    printf '%s\n%s\n' "$s" "$*" > "$LANES_DIR/$l"
+  else
+    printf '%s\n' "$s" > "$LANES_DIR/$l"
+  fi
+}
+
+scrub_diff() {
+  # echoes hits (one per line) for given branch's diff vs base. Empty = clean.
+  local branch=$1
+  git diff "$BASE_BRANCH"..."$branch" 2>/dev/null | 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
+}
+
+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_fleet() {
+  ensure_fleet_dir
+  echo ""
+  echo "── Fleet ──────────────────────────────────────────────────────"
+  printf "  %-2s  %-32s %-10s %s\n" "" "BRANCH" "STATUS" "AGE"
+  echo "────────────────────────────────────────────────────────────────"
+  local any=0 now=$(date +%s)
+  for f in "$LANES_DIR"/*; do
+    [[ -f "$f" ]] || continue
+    any=1
+    local branch state mtime secs age icon
+    branch=$(basename "$f")
+    state=$(head -n1 "$f")
+    mtime=$(file_mtime "$f")
+    secs=$((now - mtime))
+    if   [[ $secs -lt 60   ]]; then age="${secs}s"
+    elif [[ $secs -lt 3600 ]]; then age="$((secs/60))m"
+    else age="$((secs/3600))h$(( (secs%3600)/60 ))m"
+    fi
+    case $state in
+      RUNNING)  icon="$ICON_RUNNING" ;;
+      READY)    icon="$ICON_READY" ;;
+      LANDED)   icon="$ICON_LANDED" ;;
+      FAILED)   icon="$ICON_FAILED" ;;
+      CONFLICT) icon="$ICON_CONFLICT" ;;
+      *)        icon="$ICON_UNKNOWN" ;;
+    esac
+    printf "  %s  %-32s %-10s %s\n" "$icon" "$branch" "$state" "$age"
+  done
+  [[ $any -eq 0 ]] && echo "  (no lanes — run: fleet init <name>...)"
+  echo "────────────────────────────────────────────────────────────────"
+}
+
+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)"
+}
+
+land_one() {
+  local branch=$1
+  local hits
+  hits=$(scrub_diff "$branch")
+  if [[ -n "$hits" ]]; then
+    log "REFUSE LAND: $branch failed scrub-check"
+    echo "$hits" | head -10 | tee -a "$LOG"
+    set_lane_state "$branch" "CONFLICT" "scrub-check failed"
+    return 1
+  fi
+  if is_dirty_tracked; then
+    log "REFUSE LAND: $BASE_BRANCH has uncommitted tracked changes — clean before landing"
+    return 1
+  fi
+
+  log "LANDING: $branch"
+  git checkout "$BASE_BRANCH"
+  if git merge "$branch" --no-ff -m "merge: $branch"; then
+    if [[ -n "$TEST_CMD" ]]; then
+      log "running tests: $TEST_CMD"
+      if eval "$TEST_CMD" >>"$LOG" 2>&1; then
+        log "PASS: $branch landed ✓"
+      else
+        log "FAIL: tests failed — reverting $branch"
+        git reset --hard HEAD^
+        set_lane_state "$branch" "FAILED" "tests failed post-merge"
+        return 1
+      fi
+    else
+      log "no test_cmd set — trusting signal.sh's log gate"
+    fi
+    set_lane_state "$branch" "LANDED"
+    git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
+    return 0
+  else
+    log "MERGE CONFLICT: $branch"
+    git merge --abort 2>/dev/null || true
+    set_lane_state "$branch" "CONFLICT" "merge conflict with $BASE_BRANCH"
+    return 1
+  fi
+}
+
+worktree_path_for() {
+  # Echo the worktree path for branch $1, or empty if branch isn't in a worktree
+  local branch=$1
+  git worktree list --porcelain 2>/dev/null | awk -v want="refs/heads/$branch" '
+    /^worktree /{p=$2}
+    /^branch /{ if ($2==want) print p }
+  '
+}
+
+rebase_others() {
+  local landed=$1
+  for f in "$LANES_DIR"/*; do
+    local b state wt
+    b=$(basename "$f")
+    [[ "$b" == "$landed" ]] && continue
+    state=$(lane_state "$b")
+    [[ "$state" == "LANDED" || "$state" == "FAILED" ]] && continue
+    git rev-parse --verify "$b" >/dev/null 2>&1 || continue
+    log "rebase: $b onto $BASE_BRANCH"
+
+    wt=$(worktree_path_for "$b")
+    if [[ -n "$wt" ]]; then
+      # Branch is checked out in a worktree — run rebase from there
+      if git -C "$wt" rebase "$BASE_BRANCH" 2>>"$LOG"; then
+        log "rebase OK: $b (in worktree $wt)"
+      else
+        log "rebase CONFLICT: $b"
+        git -C "$wt" rebase --abort 2>/dev/null || true
+        set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
+      fi
+    else
+      # Plain branch (no worktree) — rebase via the main repo
+      if git rebase "$BASE_BRANCH" "$b" 2>>"$LOG"; then
+        log "rebase OK: $b"
+      else
+        log "rebase CONFLICT: $b"
+        git rebase --abort 2>/dev/null || true
+        set_lane_state "$b" "CONFLICT" "rebase against $BASE_BRANCH failed"
+      fi
+    fi
+  done
+  git checkout "$BASE_BRANCH" 2>/dev/null || true
+}
+
+cmd_land() {
+  local branch=${1:-}
+  [[ -z "$branch" ]] && { echo "usage: fleet land <branch>" >&2; exit 1; }
+  land_one "$branch" && rebase_others "$branch"
+}
+
+cmd_revert() {
+  local branch=${1:-}
+  [[ -z "$branch" ]] && { echo "usage: fleet revert <branch>" >&2; exit 1; }
+  local sha
+  sha=$(git log "$BASE_BRANCH" --merges --grep="merge: $branch" -n1 --format=%H)
+  [[ -z "$sha" ]] && { log "ERROR: no merge commit found for $branch on $BASE_BRANCH"; exit 1; }
+  log "reverting merge $sha (was: $branch)"
+  git checkout "$BASE_BRANCH"
+  git revert -m 1 "$sha" --no-edit
+  log "reverted: $branch"
+}
+
+cmd_start() {
+  ensure_fleet_dir
+  refuse_if_shared_tree || exit 1
+  log "daemon start (poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
+
+  while true; do
+    local ready=()
+    for f in "$LANES_DIR"/*; do
+      [[ -f "$f" && "$(head -n1 "$f")" == "READY" ]] && ready+=("$(basename "$f")")
+    done
+
+    if [[ ${#ready[@]} -gt 0 ]]; then
+      for branch in "${ready[@]}"; do
+        if land_one "$branch"; then
+          rebase_others "$branch"
+        fi
+      done
+      cmd_fleet
+    fi
+
+    local active=0
+    for f in "$LANES_DIR"/*; do
+      [[ -f "$f" ]] || continue
+      local s
+      s=$(head -n1 "$f")
+      [[ "$s" != "LANDED" && "$s" != "FAILED" ]] && active=$((active+1))
+    done
+    if [[ $active -eq 0 ]]; then
+      log "all lanes terminal — daemon exiting"
+      cmd_fleet
+      break
+    fi
+    sleep "$POLL_INTERVAL"
+  done
+}
+
+case "${1:-}" in
+  init)         shift; cmd_init "$@" ;;
+  start)        shift; cmd_start "$@" ;;
+  fleet|status) cmd_fleet ;;
+  land)         shift; cmd_land "$@" ;;
+  revert)       shift; cmd_revert "$@" ;;
+  scrub-check)  shift; cmd_scrub_check "$@" ;;
+  ""|-h|--help)
+    cat <<EOF
+fleet-ops — landing queue for concurrent Claude sessions (experimental)
+
+Usage:
+  fleet init <name>...        Create branch + worktree per name
+  fleet start                 Run the daemon (Ctrl-C to stop)
+  fleet fleet                 One-shot status view
+  fleet land <branch>         Manual land + rebase others
+  fleet revert <branch>       Revert merge commit on $BASE_BRANCH
+  fleet scrub-check <branch>  Dry-run forbidden-pattern check
+
+Config (optional): $CONFIG
+EOF
+    ;;
+  *) echo "unknown subcommand: $1" >&2; exit 1 ;;
+esac

+ 58 - 0
skills/fleet-ops/scripts/signal.sh

@@ -0,0 +1,58 @@
+#!/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/.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
+
+if [[ ! -f "$LANES_DIR/$BRANCH" ]]; then
+  echo "signal.sh ERROR: branch '$BRANCH' is not a registered lane (run: fleet track $BRANCH)" >&2
+  exit 2
+fi
+
+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:-}
+    if [[ -n "$LOG" ]]; then
+      [[ -f "$LOG" ]] || { echo "signal.sh ERROR: test log '$LOG' not found" >&2; exit 1; }
+      # crude pass detection — works for pytest, jest, go test, cargo test, mocha
+      if grep -qiE "(failed|error|fail:)" "$LOG" && ! grep -qiE "0 (failed|errors)" "$LOG"; then
+        echo "signal.sh REFUSE: test log '$LOG' shows failures" >&2
+        grep -iE "(failed|error)" "$LOG" | head -5 >&2
+        exit 1
+      fi
+    fi
+    { echo "READY"; [[ -n "$LOG" ]] && echo "log=$LOG"; } > "$LANES_DIR/$BRANCH"
+    echo "signal: $BRANCH → READY"
+    ;;
+  CONFLICT)
+    REASON=${2:-"unspecified"}
+    { echo "CONFLICT"; echo "reason=$REASON"; } > "$LANES_DIR/$BRANCH"
+    echo "signal: $BRANCH → CONFLICT ($REASON)"
+    ;;
+  RUNNING)
+    echo "RUNNING" > "$LANES_DIR/$BRANCH"
+    echo "signal: $BRANCH → RUNNING"
+    ;;
+  *)
+    echo "usage: signal.sh READY [test-log]   |   CONFLICT [reason]   |   RUNNING" >&2
+    exit 1
+    ;;
+esac

+ 1 - 1
skills/introspect/SKILL.md

@@ -113,7 +113,7 @@ What do you want to know?
 |       |- agent-{short-id}.jsonl             # Subagent transcripts
 ```
 
-Project paths use double-dash encoding: `C:\Projects\claude-mods` -> `X--Forge-claude-mods`
+Project paths use double-dash encoding: `X:\Forge\claude-mods` -> `X--Forge-claude-mods`
 
 ### Entry Types