Просмотр исходного кода

fix(skills/fleet-ops): move worktrees out of .claude/, auto-commit gitignore

Two friction fixes from a 5-parcel dogfood run (pigeon #134):

1. Default worktree_root moved from .claude/fleet/worktrees to .fleet-worktrees.
   Claude Code's global .claude/ sensitive-file guard runs before — and is
   not bypassed by — --dangerously-skip-permissions, so headless lane
   sessions could not Write/Edit anything in their worktree. New top-level
   path keeps lane sessions outside the guard. Runtime state (lanes/,
   daemon.pid, activity.log) stays under .claude/fleet/ since only the
   orchestrator writes there.

2. fleet init now auto-commits the .gitignore append (chore: gitignore
   fleet-ops runtime state) when on BASE_BRANCH with an otherwise-clean
   tree. Previously the appended .gitignore left tracked changes
   uncommitted, then the daemon refused to land with 'main has uncommitted
   tracked changes' — a 10-minute trap with no breadcrumb pointing at
   .gitignore. When auto-commit isn't safe (other dirty files, or not on
   BASE_BRANCH), prints an ACTION REQUIRED log block with the exact
   suggested command instead of sweeping unrelated changes.

SKILL.md gains a 'Headless agent compatibility' section and a note on the
auto-commit behaviour. workflow.md paths updated.
0xDarkMatter 3 недель назад
Родитель
Сommit
ce92575b06

+ 10 - 2
skills/fleet-ops/SKILL.md

@@ -118,7 +118,13 @@ export FLEET_ASCII=1
 icons=ascii
 ```
 
-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.
+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.
+
+## Headless agent compatibility
+
+**Don't put 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 at e.g. `.claude/fleet/worktrees/<lane>`.
+
+That's why the default `worktree_root` is `.fleet-worktrees/` at the repo top, not `.claude/fleet/worktrees/`. If you override `worktree_root` in config, keep it outside `.claude/` for the same reason. Runtime state (`lanes/`, `daemon.pid`, `activity.log`) is read/write from the orchestrator only and stays under `.claude/fleet/` — it never needs lane-session writes.
 
 ## Configuration
 
@@ -126,7 +132,7 @@ Optional `.claude/fleet/config` (key=value, no quotes):
 
 ```
 mode=auto                            # auto | worktree | branch
-worktree_root=.claude/fleet/worktrees
+worktree_root=.fleet-worktrees       # keep outside .claude/ — see "Headless agent compatibility"
 test_cmd=                            # if set, daemon runs this; else trust signal log
 forbidden_pattern=TODO_SCRUB|XXX
 base_branch=main
@@ -135,6 +141,8 @@ poll_interval=5
 
 Zero-config works for the common case.
 
+`fleet init` appends `.claude/fleet/` and `.fleet-worktrees/` to `.gitignore` and auto-commits 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 `fleet start`, or the daemon will refuse to land with `uncommitted tracked changes`.
+
 ## 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.

+ 4 - 2
skills/fleet-ops/references/workflow.md

@@ -10,7 +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 `.claude/fleet/worktrees/<name>/`, a status file at `.claude/fleet/lanes/<name>` (state: `RUNNING`), and deploys `signal.sh` to `.claude/fleet/signal.sh`.
+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.
 
@@ -64,7 +66,7 @@ When all lanes are terminal (`LANDED` or `FAILED`), the daemon exits. To tear do
 
 ```bash
 fleet stop                                              # if daemon still running
-git worktree remove .claude/fleet/worktrees/<name>      # for each worktree lane
+git worktree remove .fleet-worktrees/<name>             # for each worktree lane
 rm -rf .claude/fleet                                    # nuke fleet state
 ```
 

+ 54 - 4
skills/fleet-ops/scripts/fleet.sh

@@ -28,7 +28,12 @@ term_init
 
 # defaults (overridable via .claude/fleet/config: key=value, no quotes)
 MODE="auto"
-WORKTREE_ROOT=".claude/fleet/worktrees"
+# 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=""
 FORBIDDEN_PATTERN="TODO_SCRUB|XXX[^a-z]|FIXME_BEFORE_LAND"
 BASE_BRANCH="main"
@@ -50,15 +55,60 @@ file_mtime() {
 
 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
-  # 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 '.claude/fleet/' .gitignore 2>/dev/null; then
+  # 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
+    local appended=0
+    if ! grep -qxF '.claude/fleet/' .gitignore 2>/dev/null; then
       echo '.claude/fleet/' >> .gitignore
+      appended=1
+    fi
+    if ! grep -qxF '.fleet-worktrees/' .gitignore 2>/dev/null; then
+      echo '.fleet-worktrees/' >> .gitignore
+      appended=1
     fi
+    [[ $appended -eq 1 ]] && maybe_commit_gitignore
   fi
 }