Browse Source

fix(skills/fleet-ops): consistent .claude/ path, PID tracking, e2e test

Four targeted fixes flagged during dogfood review:

1. Move state dir from .fleet/ → .claude/fleet/ for consistency with
   other Claude project-local state (.claude/worktrees, .claude/skills,
   etc.). One place to gitignore, one place to nuke.

2. Add PID tracking to the daemon: writes pid to .claude/fleet/daemon.pid
   on start, traps SIGINT/SIGTERM/SIGHUP for cleanup, refuses second
   start when an existing PID is alive, auto-clears stale PID files.

3. Add `fleet stop` subcommand: reads PID file, sends SIGTERM, waits
   up to 5s, escalates to SIGKILL if needed. Recovers from orphaned
   daemons after Claude Code session ends abruptly.

4. SKILL.md gains a "Future work" section documenting JSONL log
   migration, --batch mode, and cross-session daemon as deferred
   experimental directions.

Adds tests/skills/functional/fleet-ops/e2e.sh — self-contained
end-to-end suite covering init, signal (READY + dirty refusal +
failing-log refusal), daemon land + auto-rebase, double-start
guard, revert, scrub-check, and ASCII icon fallback. 20/20
passing on Git Bash on Windows.

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

+ 29 - 9
skills/fleet-ops/SKILL.md

@@ -25,18 +25,32 @@ The skill doesn't care if there are 2 lanes or 20, doesn't care about branch nam
 
 ```
 fleet init <name>...        Create branch + worktree per name
-fleet start                 Run the daemon (Ctrl-C to stop)
+fleet start                 Run the daemon (writes pid to .claude/fleet/daemon.pid)
+fleet stop                  Signal the running daemon to exit cleanly
 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:
+## Daemon lifecycle
+
+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. Exits naturally when all lanes are terminal (`LANDED` or `FAILED`)
+
+To stop early: `fleet stop` reads the PID file, sends `SIGTERM`, waits up to 5s, escalates to `SIGKILL` if needed.
+
+If the Claude Code session ends abruptly while the daemon is running, the process is best-effort cleaned up by the OS (POSIX: child receives `SIGHUP`; Windows: depends on harness). On next `fleet start`, a stale PID file is auto-detected and cleared.
+
+`signal.sh` deploys to `.claude/fleet/signal.sh` on `init`. Sessions call:
 
 ```bash
-bash .fleet/signal.sh READY <test-log>
-bash .fleet/signal.sh CONFLICT "<reason>"
+bash .claude/fleet/signal.sh READY <test-log>
+bash .claude/fleet/signal.sh CONFLICT "<reason>"
 ```
 
 ## Decision tree
@@ -100,19 +114,19 @@ If your terminal mojibakes the status icons (⏳ ✅ 🚀 ❌ ⚠️), fall back
 
 ```bash
 export FLEET_ASCII=1
-# or in .fleet/config:
+# or in .claude/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.
+Long-path warning (Windows only): worktrees nest under `.claude/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):
+Optional `.claude/fleet/config` (key=value, no quotes):
 
 ```
 mode=auto                            # auto | worktree | branch
-worktree_root=.fleet/worktrees
+worktree_root=.claude/fleet/worktrees
 test_cmd=                            # if set, daemon runs this; else trust signal log
 forbidden_pattern=TODO_SCRUB|XXX
 base_branch=main
@@ -121,6 +135,12 @@ poll_interval=5
 
 Zero-config works for the common case.
 
+## Future work
+
+- **JSONL activity log** — currently plain text (`[HH:MM:SS] event`). Switch to JSONL when a TUI, `--json` output, or `log-ops` integration earns the cost. Migration is mechanical.
+- **`--batch` mode** — land all READY lanes in one go, test once at end. Add when dogfooding shows demand.
+- **Cross-session daemon** — currently dies with the Claude Code session. For overnight runs, a real detached process (`nohup`/`systemd`/`tmux`) is needed.
+
 ## References
 
 - `references/session-prompt.md` — copy-paste template for each Claude session
@@ -129,4 +149,4 @@ Zero-config works for the common case.
 ## Scripts
 
 - `scripts/fleet.sh` — main CLI
-- `scripts/signal.sh` — branch-aware signaler (deployed to `.fleet/signal.sh` on init)
+- `scripts/signal.sh` — branch-aware signaler (deployed to `.claude/fleet/signal.sh` on init)

+ 2 - 2
skills/fleet-ops/references/session-prompt.md

@@ -21,9 +21,9 @@ Rules:
   - 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>
+      bash .claude/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>"
+      bash .claude/fleet/signal.sh CONFLICT "<one-line reason>"
     then stop and explain.
   - Do not merge to main yourself. The fleet daemon handles landing.
 

+ 12 - 9
skills/fleet-ops/references/workflow.md

@@ -10,9 +10,9 @@ End-to-end walkthrough plus recovery scenarios. The decision tree and CLI surfac
 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`.
+Creates: a branch per name (off `main`), a worktree at `.claude/fleet/worktrees/<name>/`, a status file at `.claude/fleet/lanes/<name>` (state: `RUNNING`), and deploys `signal.sh` to `.claude/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.
+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
 
@@ -21,7 +21,7 @@ For each lane, open a Claude session pointing at that worktree (or that clone).
 Each session works in isolation, commits atomically, runs tests, and signals when ready:
 
 ```bash
-bash .fleet/signal.sh READY tests/test_auth.log
+bash .claude/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.
@@ -32,7 +32,7 @@ bash .fleet/signal.sh READY tests/test_auth.log
 fleet start
 ```
 
-Polls `.fleet/lanes/` every 5 seconds. When a lane shows `READY`:
+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
@@ -63,11 +63,14 @@ fleet fleet
 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 stop                                              # if daemon still running
+git worktree remove .claude/fleet/worktrees/<name>      # for each worktree lane
+rm -rf .claude/fleet                                    # nuke fleet state
 ```
 
-`fleet init` is idempotent — keep `.fleet/` for the next round if you want.
+`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
 
@@ -83,7 +86,7 @@ Or resolve manually:
 git checkout <lane-branch>
 # fix conflicts
 git rebase --continue
-bash .fleet/signal.sh READY <test-log>
+bash .claude/fleet/signal.sh READY <test-log>
 ```
 
 ### `FAILED` lane (tests broke `main` post-merge)
@@ -93,7 +96,7 @@ Daemon already reverted the merge. Branch still exists:
 ```bash
 git checkout <lane-branch>
 # fix the test
-bash .fleet/signal.sh READY <test-log>
+bash .claude/fleet/signal.sh READY <test-log>
 ```
 
 Daemon picks it up on next poll.

+ 57 - 8
skills/fleet-ops/scripts/fleet.sh

@@ -3,15 +3,16 @@
 # Status: experimental
 set -euo pipefail
 
-FLEET_DIR=".fleet"
+FLEET_DIR=".claude/fleet"
 LANES_DIR="$FLEET_DIR/lanes"
 LOG="$FLEET_DIR/activity.log"
 CONFIG="$FLEET_DIR/config"
+PID_FILE="$FLEET_DIR/daemon.pid"
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
-# defaults (overridable via .fleet/config: key=value, no quotes)
+# defaults (overridable via .claude/fleet/config: key=value, no quotes)
 MODE="auto"
-WORKTREE_ROOT=".fleet/worktrees"
+WORKTREE_ROOT=".claude/fleet/worktrees"
 TEST_CMD=""
 FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
 BASE_BRANCH="main"
@@ -39,10 +40,10 @@ 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
+  # Auto-ignore .claude/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
+    if [[ ! -f .gitignore ]] || ! grep -qxF '.claude/fleet/' .gitignore 2>/dev/null; then
+      echo '.claude/fleet/' >> .gitignore
     fi
   fi
 }
@@ -251,6 +252,31 @@ cmd_land() {
   land_one "$branch" && rebase_others "$branch"
 }
 
+cmd_stop() {
+  if [[ ! -f "$PID_FILE" ]]; then
+    echo "no daemon running (no $PID_FILE)" >&2
+    return 0
+  fi
+  local pid
+  pid=$(cat "$PID_FILE")
+  if ! kill -0 "$pid" 2>/dev/null; then
+    log "stale PID file (pid $pid not alive) — clearing"
+    rm -f "$PID_FILE"
+    return 0
+  fi
+  log "sending SIGTERM to daemon (pid $pid)"
+  kill -TERM "$pid" 2>/dev/null || true
+  # Wait up to 5s for graceful exit
+  local i
+  for i in 1 2 3 4 5; do
+    sleep 1
+    kill -0 "$pid" 2>/dev/null || { log "daemon stopped"; return 0; }
+  done
+  log "daemon didn't exit on SIGTERM, sending SIGKILL"
+  kill -KILL "$pid" 2>/dev/null || true
+  rm -f "$PID_FILE"
+}
+
 cmd_revert() {
   local branch=${1:-}
   [[ -z "$branch" ]] && { echo "usage: fleet revert <branch>" >&2; exit 1; }
@@ -263,10 +289,31 @@ cmd_revert() {
   log "reverted: $branch"
 }
 
+daemon_cleanup() {
+  log "daemon stopping (pid $$)"
+  rm -f "$PID_FILE"
+}
+
 cmd_start() {
   ensure_fleet_dir
   refuse_if_shared_tree || exit 1
-  log "daemon start (poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
+
+  # Refuse if a daemon is already running
+  if [[ -f "$PID_FILE" ]]; then
+    local existing_pid
+    existing_pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
+    if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
+      log "ERROR: daemon already running (pid $existing_pid). Run: fleet stop"
+      exit 1
+    else
+      log "stale PID file (pid $existing_pid not alive) — clearing"
+      rm -f "$PID_FILE"
+    fi
+  fi
+
+  echo "$$" > "$PID_FILE"
+  trap daemon_cleanup EXIT INT TERM HUP
+  log "daemon start (pid $$, poll: ${POLL_INTERVAL}s, test_cmd: ${TEST_CMD:-<none>})"
 
   while true; do
     local ready=()
@@ -302,6 +349,7 @@ cmd_start() {
 case "${1:-}" in
   init)         shift; cmd_init "$@" ;;
   start)        shift; cmd_start "$@" ;;
+  stop)         cmd_stop ;;
   fleet|status) cmd_fleet ;;
   land)         shift; cmd_land "$@" ;;
   revert)       shift; cmd_revert "$@" ;;
@@ -312,7 +360,8 @@ 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 start                 Run the daemon (writes pid to $PID_FILE)
+  fleet stop                  Signal the running daemon to exit cleanly
   fleet fleet                 One-shot status view
   fleet land <branch>         Manual land + rebase others
   fleet revert <branch>       Revert merge commit on $BASE_BRANCH

+ 1 - 1
skills/fleet-ops/scripts/signal.sh

@@ -8,7 +8,7 @@ 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"
+LANES_DIR="$MAIN_REPO_ROOT/.claude/fleet/lanes"
 BRANCH=$(git branch --show-current 2>/dev/null || true)
 
 if [[ -z "$BRANCH" ]]; then

+ 174 - 0
tests/skills/functional/fleet-ops/e2e.sh

@@ -0,0 +1,174 @@
+#!/usr/bin/env bash
+# fleet-ops e2e test — full lifecycle in a throwaway repo
+# Run from any cwd. Tears down its own scratch dir.
+set -euo pipefail
+
+SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)/skills/fleet-ops"
+FLEET="$SKILL_DIR/scripts/fleet.sh"
+SCRATCH="${TMPDIR:-/tmp}/fleet-ops-e2e-$$"
+PASS=0
+FAIL=0
+
+# colors (fall back if no terminal)
+if [[ -t 1 ]]; then
+  GREEN=$'\033[32m'; RED=$'\033[31m'; CYAN=$'\033[36m'; DIM=$'\033[2m'; OFF=$'\033[0m'
+else
+  GREEN=""; RED=""; CYAN=""; DIM=""; OFF=""
+fi
+
+step() { echo ""; echo "${CYAN}── $* ──${OFF}"; }
+ok()   { echo "${GREEN}PASS${OFF}: $*"; PASS=$((PASS+1)); }
+fail() { echo "${RED}FAIL${OFF}: $*"; FAIL=$((FAIL+1)); }
+note() { echo "${DIM}  $*${OFF}"; }
+
+cleanup() {
+  # Kill any daemons we spawned
+  if [[ -f "$SCRATCH/.claude/fleet/daemon.pid" ]]; then
+    local pid
+    pid=$(cat "$SCRATCH/.claude/fleet/daemon.pid" 2>/dev/null || echo "")
+    [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null || true
+  fi
+  pkill -f "fleet.sh start" 2>/dev/null || true
+  rm -rf "$SCRATCH"
+}
+trap cleanup EXIT
+
+echo "fleet-ops e2e test"
+echo "  skill: $SKILL_DIR"
+echo "  scratch: $SCRATCH"
+
+# ── setup ──
+step "setup mock repo"
+mkdir -p "$SCRATCH"
+cd "$SCRATCH"
+git init -b main -q
+echo "init" > README.md
+git add . && git -c user.email=e2e@test -c user.name=e2e commit -q -m init
+note "repo at $SCRATCH"
+
+# ── init ──
+step "fleet init alpha beta"
+bash "$FLEET" init alpha beta >/dev/null 2>&1
+[[ -d .claude/fleet/lanes ]] && ok "lanes/ created" || fail "lanes/ missing"
+[[ -d .claude/fleet/worktrees/alpha ]] && ok "alpha worktree created" || fail "alpha worktree missing"
+[[ -d .claude/fleet/worktrees/beta ]] && ok "beta worktree created" || fail "beta worktree missing"
+[[ -f .claude/fleet/signal.sh ]] && ok "signal.sh deployed" || fail "signal.sh not deployed"
+grep -qxF '.claude/fleet/' .gitignore && ok ".claude/fleet/ in .gitignore" || fail ".gitignore not updated"
+[[ "$(cat .claude/fleet/lanes/alpha)" == "RUNNING" ]] && ok "alpha state = RUNNING" || fail "alpha state wrong"
+[[ "$(cat .claude/fleet/lanes/beta)" == "RUNNING" ]] && ok "beta state = RUNNING" || fail "beta state wrong"
+
+# ── work in alpha lane ──
+step "do work in alpha worktree, signal READY"
+(
+  cd .claude/fleet/worktrees/alpha
+  echo "alpha feature" > a.txt
+  git add . && git -c user.email=e2e@test -c user.name=e2e commit -q -m "feat: alpha"
+)
+echo "0 failed, 1 passed" > "$SCRATCH/alpha-test.log"
+( cd .claude/fleet/worktrees/alpha && bash "$SCRATCH/.claude/fleet/signal.sh" READY "$SCRATCH/alpha-test.log" >/dev/null )
+[[ "$(head -n1 .claude/fleet/lanes/alpha)" == "READY" ]] && ok "alpha state = READY after signal" || fail "alpha not READY"
+
+step "signal.sh refuses dirty tree"
+(
+  cd .claude/fleet/worktrees/alpha
+  echo "uncommitted change" >> a.txt
+)
+( cd .claude/fleet/worktrees/alpha && bash "$SCRATCH/.claude/fleet/signal.sh" READY "$SCRATCH/alpha-test.log" 2>/dev/null ) \
+  && fail "signal.sh accepted dirty tree" || ok "signal.sh refused dirty tree"
+( cd .claude/fleet/worktrees/alpha && git checkout -- a.txt )  # clean back up
+
+step "signal.sh refuses failing test log"
+echo "ERROR: 3 tests failed" > "$SCRATCH/bad-test.log"
+( cd .claude/fleet/worktrees/alpha && bash "$SCRATCH/.claude/fleet/signal.sh" READY "$SCRATCH/bad-test.log" 2>/dev/null ) \
+  && fail "signal.sh accepted failing log" || ok "signal.sh refused failing log"
+# Re-signal with good log to reset state for daemon test
+( cd .claude/fleet/worktrees/alpha && bash "$SCRATCH/.claude/fleet/signal.sh" READY "$SCRATCH/alpha-test.log" >/dev/null )
+
+# ── work in beta lane ──
+step "do work in beta worktree, signal READY"
+(
+  cd .claude/fleet/worktrees/beta
+  echo "beta feature" > b.txt
+  git add . && git -c user.email=e2e@test -c user.name=e2e commit -q -m "feat: beta"
+)
+echo "0 failed, 2 passed" > "$SCRATCH/beta-test.log"
+( cd .claude/fleet/worktrees/beta && bash "$SCRATCH/.claude/fleet/signal.sh" READY "$SCRATCH/beta-test.log" >/dev/null )
+
+# ── daemon ──
+step "start daemon (background) and watch it land both lanes"
+( cd "$SCRATCH" && bash "$FLEET" start ) &
+DAEMON_PID=$!
+note "wrapper PID: $DAEMON_PID"
+sleep 4
+
+# The daemon may finish before we sleep — verify via log instead
+if grep -q "daemon start (pid " .claude/fleet/activity.log; then
+  ok "daemon.pid recorded in activity log"
+else
+  fail "daemon never logged a start"
+fi
+
+# Wait up to 15s for both lanes to land or daemon to exit
+for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
+  alpha_state=$(head -n1 .claude/fleet/lanes/alpha 2>/dev/null || echo "MISSING")
+  beta_state=$(head -n1 .claude/fleet/lanes/beta 2>/dev/null || echo "MISSING")
+  [[ "$alpha_state" == "LANDED" && "$beta_state" == "LANDED" ]] && break
+  sleep 1
+done
+
+[[ "$(head -n1 .claude/fleet/lanes/alpha)" == "LANDED" ]] && ok "alpha LANDED" || fail "alpha = $(head -n1 .claude/fleet/lanes/alpha)"
+[[ "$(head -n1 .claude/fleet/lanes/beta)" == "LANDED" ]]  && ok "beta LANDED"  || fail "beta = $(head -n1 .claude/fleet/lanes/beta)"
+
+# Verify merge commits on main
+git -C "$SCRATCH" log --oneline main | grep -q "merge: alpha" && ok "merge: alpha commit on main" || fail "no merge: alpha commit"
+git -C "$SCRATCH" log --oneline main | grep -q "merge: beta"  && ok "merge: beta commit on main"  || fail "no merge: beta commit"
+
+# Daemon should self-exit when all lanes terminal
+sleep 2
+if [[ -f .claude/fleet/daemon.pid ]]; then
+  fail "daemon.pid still present after all lanes terminal"
+else
+  ok "daemon.pid removed after self-exit"
+fi
+wait "$DAEMON_PID" 2>/dev/null || true
+
+# ── refuse double-start ──
+step "refuse second daemon while one is running"
+output=$( ( cd "$SCRATCH" && bash "$FLEET" start ) 2>&1 || true )
+if echo "$output" | grep -qiE "(already running|all lanes terminal|daemon exiting)"; then
+  ok "second start handled (refused or terminal)"
+else
+  fail "second start unexpected"
+  note "actual output: $output"
+fi
+pkill -f "fleet.sh start" 2>/dev/null || true
+sleep 1
+
+# ── revert ──
+step "fleet revert backs out a landed merge"
+bash "$FLEET" revert alpha >/dev/null 2>&1
+git -C "$SCRATCH" log --oneline main | head -1 | grep -qi "revert" && ok "revert commit created on main" || fail "no revert commit"
+
+# ── scrub-check ──
+step "scrub-check catches forbidden patterns"
+git -C "$SCRATCH" checkout -b scrub-test main >/dev/null 2>&1
+echo "// TODO_SCRUB: remove before landing" > scrub.txt
+git -C "$SCRATCH" add scrub.txt
+git -C "$SCRATCH" -c user.email=e2e@test -c user.name=e2e commit -q -m "test: scrub"
+# scrub-check exits non-zero on hits (intended) — capture output before grep to avoid pipefail
+scrub_out=$(bash "$FLEET" scrub-check scrub-test 2>&1 || true)
+echo "$scrub_out" | grep -q "FORBIDDEN" && ok "scrub-check flagged TODO_SCRUB" || fail "scrub-check missed pattern"
+git -C "$SCRATCH" checkout main >/dev/null 2>&1
+
+# ── ASCII fallback ──
+step "FLEET_ASCII=1 swaps icons"
+ascii_out=$(FLEET_ASCII=1 bash "$FLEET" fleet 2>&1 || true)
+echo "$ascii_out" | grep -qE '\[\*\]|\[\+\]|\[\.\]' && ok "ASCII icons rendered" || fail "ASCII icons not used"
+
+# ── summary ──
+echo ""
+echo "═══════════════════════════════════════"
+echo "  ${GREEN}PASS: $PASS${OFF}    ${RED}FAIL: $FAIL${OFF}"
+echo "═══════════════════════════════════════"
+
+[[ $FAIL -eq 0 ]] && exit 0 || exit 1