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

feat(git-ops): absorb status + add worktree survey + boundaries rule (v2.4.3)

Refactor: instead of growing skill count, fold the previously-considered
git-status skill into git-ops as Tier 1 inline ops, and absorb worktree
visibility under the same orchestrator. Net change: +0 skills, +1 rule.

git-ops gains:
- scripts/status.sh — rich repo overview (HEAD, sync, tree, worktrees, branches,
  optional PR). Exit codes 0/1/2 for composability.
- scripts/worktree-survey.sh — read-only per-worktree triage. Categorises each
  as (trunk) / PRUNABLE / has WIP / unpushed / in-flight / GHOST / ORPHAN.
  Detects drift between git registration and .claude/worktrees/ filesystem.
- New "Worktree Operations" section: tiered map (T1 survey, T2 create/land/
  prune-clean, T3 remove), survey-first discipline, and explicit boundary rules.
- Quick Reference table updated with worktree ops.
- Description + triggers expanded to include status, worktree, prunable etc.

rules/worktree-boundaries.md (new) — promoted from user-global into the plugin.
Hard rule: never `rm -rf .claude/worktrees/`, never `git add -A` when worktree
gitlinks are untracked, never decide another session's worktree is orphaned.

Version bump: 2.4.2 → 2.4.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 месяц назад
Родитель
Сommit
b76d32d8b3

+ 3 - 2
.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "claude-mods",
   "name": "claude-mods",
-  "version": "2.4.2",
+  "version": "2.4.3",
   "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 68 skills, 3 commands, 5 rules, 4 hooks, 13 output styles, modern CLI tools",
   "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 68 skills, 3 commands, 5 rules, 4 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
@@ -123,7 +123,8 @@
       "rules/commit-style.md",
       "rules/commit-style.md",
       "rules/naming-conventions.md",
       "rules/naming-conventions.md",
       "rules/skill-agent-updates.md",
       "rules/skill-agent-updates.md",
-      "rules/thinking.md"
+      "rules/thinking.md",
+      "rules/worktree-boundaries.md"
     ],
     ],
     "hooks": [
     "hooks": [
       "hooks/pre-commit-lint.sh",
       "hooks/pre-commit-lint.sh",

+ 37 - 0
rules/worktree-boundaries.md

@@ -0,0 +1,37 @@
+# Worktree Boundaries
+
+Never touch `.claude/worktrees/` in any repo. Never touch git worktrees, submodules, gitlinks, or agent-spawned ephemeral dirs in repos outside your current working scope.
+
+## The rule
+
+**Worktrees belong to the project that owns them.** If private-project housekeeping touches file X in repo Y, that does NOT extend to `.claude/worktrees/` in repo Y. Worktrees are the private state of whichever agent, session, or human spawned them. They may look orphaned and aren't.
+
+## What counts as "don't touch"
+
+- Do not `rm -rf .claude/worktrees/` in any repo
+- Do not `git rm` or `git rm --cached` worktree entries
+- Do not stage deletions of worktree dirs via `git add -A` (this is the subtle one — `-A` sweeps up changes you didn't intend)
+- Do not commit changes that reference `.claude/worktrees/` paths
+- Do not reason about whether a worktree is "orphaned" unless the owning project explicitly asks
+
+## Why
+
+- Worktree names like `agent-<hash>` look like ephemeral agent artifacts but may be active sessions
+- A gitlink/submodule pointing at a worktree is not garbage — it's a reference with meaning to the owning session
+- Agent-spawned worktrees may contain uncommitted work the user wants to inspect
+- Cross-project cleanup assumes context you don't have; when a project wants cleanup it will ask its own session
+
+## The specific failure this came from (2026-04-19)
+
+During a private-project ecosystem-wide "commit + push all tool repos" pass, `git add -A` in flarecrawl staged gitlinks to 9 agent worktrees (from background agents running inside flarecrawl). Those gitlinks were committed and pushed as part of "chore: sync tool state". Then a subsequent `rm -rf .claude/worktrees/` deleted the filesystem dirs, creating a dirty state. The user correctly pushed back — private-project housekeeping has no business touching another project's agent state.
+
+## Applied corrections when running bulk commits across repos
+
+- Use explicit file paths to `git add`, not `-A` or `.`, when the repo contains any `.claude/` directory
+- Inspect `git status --short` before any bulk commit loop; if `.claude/worktrees/` appears, STOP and ask
+- Never include worktrees in commit messages, scripts, or cleanup routines
+- If a repo's `.claude/` state looks "dirty" during cross-project work, that's the repo's problem, not yours
+
+## Scope this rule covers
+
+All projects. Never make exceptions "just for this session". If a worktree ever looks like it needs cleanup, ask the user explicitly before touching it.

+ 63 - 4
skills/git-ops/SKILL.md

@@ -1,11 +1,11 @@
 ---
 ---
 name: git-ops
 name: git-ops
-description: "Git operations orchestrator - commits, PRs, branch management, releases, changelog. Routes lightweight reads inline, dispatches heavy work to background Sonnet agent. Triggers on: commit, push, pull request, create PR, git status, git diff, rebase, stash, branch, merge, release, tag, changelog, semver, cherry-pick, bisect, worktree."
+description: "Full git + worktree orchestrator. Rich status survey, per-worktree triage (prunable/WIP/ghost/orphan), commits, PRs, branches, releases, rebases — reads run inline, writes dispatch to a background Sonnet agent. Triggers on: status, state, where are we, git status, anything to commit, anything to push, commit, push, pull request, create PR, git diff, rebase, stash, branch, merge, release, tag, changelog, semver, cherry-pick, bisect, worktree, worktree survey, prunable worktrees, land worktree."
 license: MIT
 license: MIT
 allowed-tools: "Read Bash Glob Grep Agent TaskCreate TaskUpdate"
 allowed-tools: "Read Bash Glob Grep Agent TaskCreate TaskUpdate"
 metadata:
 metadata:
   author: claude-mods
   author: claude-mods
-  related-skills: review, ci-cd-ops
+  related-skills: review, ci-cd-ops, push-gate
 ---
 ---
 
 
 # Git Ops
 # Git Ops
@@ -45,7 +45,9 @@ No subagent needed. Execute directly via Bash for instant results.
 
 
 | Operation | Command |
 | Operation | Command |
 |-----------|---------|
 |-----------|---------|
-| Status | `git status --short` |
+| **Status (rich)** | `bash $HOME/.claude/skills/git-ops/scripts/status.sh` — one-shot HEAD + sync + tree + worktrees + branches + PR |
+| **Worktree survey** | `bash $HOME/.claude/skills/git-ops/scripts/worktree-survey.sh` — per-worktree state, drift detection, prunable/WIP/ghost/orphan triage |
+| Status (bare) | `git status --short` |
 | Log | `git log --oneline -20` |
 | Log | `git log --oneline -20` |
 | Diff (unstaged) | `git diff --stat` |
 | Diff (unstaged) | `git diff --stat` |
 | Diff (staged) | `git diff --cached --stat` |
 | Diff (staged) | `git diff --cached --stat` |
@@ -57,6 +59,7 @@ No subagent needed. Execute directly via Bash for instant results.
 | Show commit | `git show [hash] --stat` |
 | Show commit | `git show [hash] --stat` |
 | Reflog | `git reflog --oneline -20` |
 | Reflog | `git reflog --oneline -20` |
 | Tags | `git tag --list --sort=-v:refname` |
 | Tags | `git tag --list --sort=-v:refname` |
+| Worktree list | `git worktree list` |
 | PR list | `gh pr list` |
 | PR list | `gh pr list` |
 | PR status | `gh pr view [N]` |
 | PR status | `gh pr view [N]` |
 | Issue list | `gh issue list` |
 | Issue list | `gh issue list` |
@@ -65,6 +68,11 @@ No subagent needed. Execute directly via Bash for instant results.
 
 
 For T1 operations, format results cleanly and present directly. Use `delta` for diffs when available.
 For T1 operations, format results cleanly and present directly. Use `delta` for diffs when available.
 
 
+**When to reach for the bundled scripts:**
+- User asks "status", "where are we", "anything to commit", "anything to push" → `status.sh`
+- User asks about worktrees, prunable branches, drift, "what can we clean up" → `worktree-survey.sh`
+- Both scripts exit 0 if clean, 1 if attention needed, 2 if not-a-repo — composable.
+
 ### Tier 2: Safe Writes - Dispatch to Agent
 ### Tier 2: Safe Writes - Dispatch to Agent
 
 
 Gather relevant context, then dispatch to `git-agent` (background, Sonnet).
 Gather relevant context, then dispatch to `git-agent` (background, Sonnet).
@@ -289,6 +297,52 @@ When user encounters merge conflicts:
 3. **Present options:** ours, theirs, manual resolution
 3. **Present options:** ours, theirs, manual resolution
 4. **After resolution:** Dispatch to git-agent (T2) for staging and continue
 4. **After resolution:** Dispatch to git-agent (T2) for staging and continue
 
 
+## Worktree Operations
+
+Worktrees are first-class in this skill. The classification is:
+
+| Op | Tier | How |
+|----|------|-----|
+| **Survey** | T1 | `bash scripts/worktree-survey.sh` — read-only, reports per-worktree state + drift |
+| **Create** | T2 | `git worktree add .claude/worktrees/<name> -b <branch>` via agent (respects project conventions) |
+| **Land** | T2 | Rebase worktree branch onto trunk + test + fast-forward. Multi-step procedure — see "Worktree Land Procedure" below |
+| **Prune (clean)** | T2 | `git worktree prune` for ghost entries (registered but FS-missing). Always safe, no data loss possible |
+| **Remove** | **T3** | `git worktree remove <path>` — destroys filesystem state. Requires preflight + explicit confirm per worktree |
+
+### Survey-first discipline
+
+Never recommend prune/remove without first running `scripts/worktree-survey.sh`
+and presenting the output to the user. The survey categorises each worktree as:
+
+- `(trunk)` — the main repo itself, never prune
+- `PRUNABLE` — merged into trunk, no uncommitted work, no unpushed commits → safe to remove
+- `has WIP` — uncommitted changes → commit or stash first, never auto-remove
+- `unpushed` — commits ahead of upstream → push or cherry-pick before remove
+- `in-flight` — not merged, not dirty → probably still in active use
+- `GHOST` — registered but filesystem gone → `git worktree prune` fixes
+- `UNREGISTERED` / orphan — filesystem dir with no git entry → **DO NOT touch without explicit review**
+
+### Worktree Land Procedure (T2)
+
+For landing a branch from a worktree onto the trunk (rebase + test + ff):
+
+1. Verify preconditions: worktree clean, branch ahead of trunk, not already merged
+2. Fetch trunk, rebase worktree branch onto it
+3. Run project test command (detect from `package.json` / `pyproject.toml` / `justfile`)
+4. On test pass: fast-forward trunk to the rebased tip
+5. Do NOT push — that's a separate explicit step (and should go through `push-gate`)
+
+Dispatch this to `git-agent` as a T2 operation with the worktree path + trunk name.
+
+### Boundaries (HARD RULE)
+
+See `rules/worktree-boundaries.md`. Summary:
+
+- **Never** `rm -rf .claude/worktrees/` — the orphan count in survey is informational, never a cleanup cue
+- **Never** `git add -A` when `.claude/worktrees/` has untracked entries (sweeps gitlinks into commits)
+- **Never** decide another session's worktree is "orphaned" — ask first
+- Cross-project work stays cross-project; a worktree in repo X is never our concern when we're operating on repo Y
+
 ## Decision Logic
 ## Decision Logic
 
 
 When a git-related request arrives, follow this flow:
 When a git-related request arrives, follow this flow:
@@ -322,7 +376,8 @@ When a git-related request arrives, follow this flow:
 
 
 | Task | Tier | Inline/Agent |
 | Task | Tier | Inline/Agent |
 |------|------|-------------|
 |------|------|-------------|
-| Check status | T1 | Inline |
+| Check status (rich) | T1 | Inline (`scripts/status.sh`) |
+| Worktree survey | T1 | Inline (`scripts/worktree-survey.sh`) |
 | View diff | T1 | Inline |
 | View diff | T1 | Inline |
 | View log | T1 | Inline |
 | View log | T1 | Inline |
 | List PRs | T1 | Inline |
 | List PRs | T1 | Inline |
@@ -334,12 +389,16 @@ When a git-related request arrives, follow this flow:
 | Stash push/pop | T2 | Agent |
 | Stash push/pop | T2 | Agent |
 | Cherry-pick | T2 | Agent |
 | Cherry-pick | T2 | Agent |
 | Create branch | T2 | Agent |
 | Create branch | T2 | Agent |
+| Create worktree | T2 | Agent |
+| Land worktree | T2 | Agent (rebase + test + ff) |
+| Prune ghost worktrees | T2 | Agent (`git worktree prune`) |
 | Rebase | T3 | Agent (preflight) |
 | Rebase | T3 | Agent (preflight) |
 | Force push | T3 | Agent (preflight) |
 | Force push | T3 | Agent (preflight) |
 | Reset --hard | T3 | Agent (preflight) |
 | Reset --hard | T3 | Agent (preflight) |
 | Delete branch | T3 | Agent (preflight) |
 | Delete branch | T3 | Agent (preflight) |
 | Discard changes | T3 | Agent (preflight) |
 | Discard changes | T3 | Agent (preflight) |
 | Merge to main | T3 | Agent (preflight) |
 | Merge to main | T3 | Agent (preflight) |
+| Remove worktree | T3 | Agent (preflight per worktree) |
 
 
 ## Tools
 ## Tools
 
 

+ 152 - 0
skills/git-ops/scripts/status.sh

@@ -0,0 +1,152 @@
+#!/bin/bash
+# git-status - one-shot read-only repo overview
+#
+# Usage:
+#   bash status.sh             # survey current directory
+#   bash status.sh <repo-path> # survey explicit path
+#
+# Exit codes:
+#   0  CLEAN (nothing ahead/behind, tree empty, no stashes)
+#   1  NON-CLEAN (at least one signal non-zero)
+#   2  Not a git repo
+
+set -u
+
+REPO="${1:-$PWD}"
+
+# Guard: must be inside a git repo
+if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  echo "not-a-repo: $REPO"
+  exit 2
+fi
+
+REPO_ROOT=$(git -C "$REPO" rev-parse --show-toplevel)
+cd "$REPO_ROOT" || { echo "cannot-cd: $REPO_ROOT"; exit 2; }
+
+# Best-effort fetch — record failure but don't abort
+FETCH_OK=true
+git fetch --quiet 2>/dev/null || FETCH_OK=false
+
+# Age of last successful fetch (mtime of FETCH_HEAD)
+fetch_age=-1
+if [ -f .git/FETCH_HEAD ]; then
+  if fetch_mtime=$(stat -c '%Y' .git/FETCH_HEAD 2>/dev/null); then
+    :
+  elif fetch_mtime=$(stat -f '%m' .git/FETCH_HEAD 2>/dev/null); then
+    :
+  else
+    fetch_mtime=""
+  fi
+  if [ -n "$fetch_mtime" ]; then
+    fetch_age=$(( $(date +%s) - fetch_mtime ))
+  fi
+fi
+
+# Branch / HEAD
+BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "(detached)")
+HEAD_INFO=$(git log -1 --format='%h %s (%ar)' 2>/dev/null || echo "(no commits)")
+
+# Sync with upstream (if configured)
+AHEAD=0
+BEHIND=0
+if [ "$BRANCH" != "(detached)" ] && git rev-parse '@{u}' >/dev/null 2>&1; then
+  AHEAD=$(git rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
+  BEHIND=$(git rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
+  SYNC_LINE="$AHEAD ahead / $BEHIND behind"
+else
+  SYNC_LINE="no upstream"
+fi
+
+# Working tree
+STAGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
+UNSTAGED=$(git diff --name-only | wc -l | tr -d ' ')
+UNTRACKED=$(git ls-files --others --exclude-standard | wc -l | tr -d ' ')
+STASHES=$(git stash list | wc -l | tr -d ' ')
+
+# Shortstat if there's uncommitted change
+SHORTSTAT=""
+if [ "$STAGED" -gt 0 ] || [ "$UNSTAGED" -gt 0 ]; then
+  SHORTSTAT=$(git diff HEAD --shortstat 2>/dev/null \
+    | sed 's/^ *//' \
+    | sed -E 's/([0-9]+) files? changed, //' \
+    | sed -E 's/([0-9]+) insertions?\(\+\)/+\1/' \
+    | sed -E 's/([0-9]+) deletions?\(-\)/-\1/' \
+    | tr -d '()')
+fi
+
+# Worktrees — registered vs filesystem
+WT_REGISTERED=$(git worktree list 2>/dev/null | wc -l | tr -d ' ')
+WT_FS=0
+if [ -d .claude/worktrees ]; then
+  WT_FS=$(find .claude/worktrees -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
+fi
+
+# Branches
+BR_LOCAL=$(git branch 2>/dev/null | wc -l | tr -d ' ')
+BR_REMOTE=$(git branch -r 2>/dev/null | wc -l | tr -d ' ')
+
+# Optional PR linkage (graceful if gh absent or no PR)
+PR_LINE=""
+if command -v gh >/dev/null 2>&1 && [ "$BRANCH" != "(detached)" ]; then
+  PR_JSON=$(gh pr view --json number,url,state 2>/dev/null || true)
+  if [ -n "$PR_JSON" ] && command -v jq >/dev/null 2>&1; then
+    PR_LINE=$(printf '%s' "$PR_JSON" \
+      | jq -r 'if .number then "PR #\(.number): \(.url) [\(.state)]" else empty end' 2>/dev/null)
+  fi
+fi
+
+# --- Output -----------------------------------------------------------------
+echo "repo:    $REPO_ROOT"
+echo "branch:  $BRANCH"
+echo "HEAD:    $HEAD_INFO"
+echo "sync:    $SYNC_LINE"
+
+TREE_LINE="$STAGED staged / $UNSTAGED unstaged / $UNTRACKED untracked / $STASHES stashes"
+if [ -n "$SHORTSTAT" ]; then
+  TREE_LINE="$TREE_LINE ($SHORTSTAT)"
+fi
+echo "tree:    $TREE_LINE"
+
+# Only show worktrees line if there are multiple registered OR .claude/worktrees exists
+if [ "$WT_REGISTERED" -gt 1 ] || [ "$WT_FS" -gt 0 ]; then
+  echo "trees:   $WT_REGISTERED registered / $WT_FS in .claude/worktrees"
+fi
+
+echo "branch:  $BR_LOCAL local / $BR_REMOTE remote"
+
+if [ -n "$PR_LINE" ]; then
+  echo "pr:      $PR_LINE"
+fi
+
+# Fetch failure warning
+if [ "$FETCH_OK" = false ]; then
+  if [ "$fetch_age" -ge 0 ]; then
+    if   [ "$fetch_age" -gt 86400 ]; then age_display="$((fetch_age / 86400))d ago"
+    elif [ "$fetch_age" -gt 3600  ]; then age_display="$((fetch_age / 3600))h ago"
+    elif [ "$fetch_age" -gt 60    ]; then age_display="$((fetch_age / 60))m ago"
+    else age_display="${fetch_age}s ago"
+    fi
+  else
+    age_display="unknown"
+  fi
+  echo "fetch:   FAILED (last successful: $age_display)"
+fi
+
+# --- Verdict ----------------------------------------------------------------
+echo ""
+if [ "$AHEAD" -eq 0 ] && [ "$BEHIND" -eq 0 ] && \
+   [ "$STAGED" -eq 0 ] && [ "$UNSTAGED" -eq 0 ] && \
+   [ "$UNTRACKED" -eq 0 ] && [ "$STASHES" -eq 0 ]; then
+  echo "verdict: CLEAN"
+  exit 0
+fi
+
+FLAGS=""
+[ "$AHEAD"     -gt 0 ] && FLAGS="$FLAGS ahead"
+[ "$BEHIND"    -gt 0 ] && FLAGS="$FLAGS behind"
+[ "$STAGED"    -gt 0 ] && FLAGS="$FLAGS staged"
+[ "$UNSTAGED"  -gt 0 ] && FLAGS="$FLAGS unstaged"
+[ "$UNTRACKED" -gt 0 ] && FLAGS="$FLAGS untracked"
+[ "$STASHES"   -gt 0 ] && FLAGS="$FLAGS stashes"
+echo "verdict: NON-CLEAN —${FLAGS}"
+exit 1

+ 192 - 0
skills/git-ops/scripts/worktree-survey.sh

@@ -0,0 +1,192 @@
+#!/bin/bash
+# worktree-survey.sh - Read-only worktree survey + triage
+#
+# Enumerates registered worktrees, cross-references with .claude/worktrees/
+# filesystem entries, classifies each, and emits a table + summary.
+#
+# NEVER mutates. Respects rules/worktree-boundaries.md.
+#
+# Usage:
+#   bash worktree-survey.sh              # survey current repo
+#   bash worktree-survey.sh <repo-path>  # survey explicit repo
+#
+# Exit codes:
+#   0  All worktrees healthy (no ghosts, orphans, or prunable)
+#   1  Attention needed (ghosts, orphans, or prunable candidates found)
+#   2  Not a git repo
+
+set -u
+
+REPO="${1:-$PWD}"
+
+if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  echo "not-a-repo: $REPO"
+  exit 2
+fi
+
+REPO_ROOT=$(git -C "$REPO" rev-parse --show-toplevel)
+cd "$REPO_ROOT" || exit 2
+
+# Detect trunk branch
+TRUNK="main"
+if ! git rev-parse --verify main >/dev/null 2>&1; then
+  if git rev-parse --verify master >/dev/null 2>&1; then
+    TRUNK="master"
+  fi
+fi
+
+# Parse `git worktree list --porcelain` into TSV: path \t branch \t head
+TMP_REG=$(mktemp)
+git worktree list --porcelain 2>/dev/null | awk '
+  /^worktree / { p=substr($0,10); b=""; h=""; next }
+  /^HEAD /     { h=substr($0,6); next }
+  /^branch /   { b=substr($0, 19); next }  # skip "branch refs/heads/" (18 chars)
+  /^detached/  { b="(detached)"; next }
+  /^$/         { if (p != "") { print p"\t"b"\t"h } p=""; b=""; h=""; next }
+  END          { if (p != "") print p"\t"b"\t"h }
+' > "$TMP_REG"
+
+WT_COUNT=$(wc -l < "$TMP_REG" | tr -d ' ')
+
+# Enumerate filesystem entries in .claude/worktrees/ (canonical absolute paths)
+TMP_FS=$(mktemp)
+if [ -d .claude/worktrees ]; then
+  while IFS= read -r d; do
+    [ -z "$d" ] && continue
+    (cd "$d" 2>/dev/null && pwd -P)
+  done < <(find .claude/worktrees -maxdepth 1 -mindepth 1 -type d 2>/dev/null) > "$TMP_FS"
+fi
+
+FS_COUNT=$(wc -l < "$TMP_FS" | tr -d ' ')
+
+# Counters
+GHOSTS=0
+PRUNABLE=0
+WIP=0
+UNPUSHED=0
+ORPHANS=0
+
+# Header
+printf "%-40s %-20s %-22s %-12s %s\n" "PATH" "BRANCH" "STATE" "AGE" "VERDICT"
+echo "──────────────────────────────────────────────────────────────────────────────────────────────"
+
+# --- Process each registered worktree ---
+while IFS=$'\t' read -r path branch head; do
+  [ -z "$path" ] && continue
+
+  # Canonical absolute (for orphan comparison)
+  canon_path=$( (cd "$path" 2>/dev/null && pwd -P) || echo "$path" )
+
+  # Display path
+  if [ "$path" = "$REPO_ROOT" ]; then
+    disp="<trunk>"
+  else
+    disp="${path#$REPO_ROOT/}"
+    [ ${#disp} -gt 38 ] && disp="...${disp: -35}"
+  fi
+
+  # Ghost: registered but filesystem gone
+  if [ ! -d "$path" ]; then
+    printf "%-40s %-20s %-22s %-12s %s\n" "<$disp>" "$branch" "FILESYSTEM GONE" "?" "git worktree prune"
+    GHOSTS=$((GHOSTS+1))
+    continue
+  fi
+
+  # Tree state
+  staged=$(git -C "$path" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
+  unstaged=$(git -C "$path" diff --name-only 2>/dev/null | wc -l | tr -d ' ')
+  untracked=$(git -C "$path" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
+
+  # Upstream sync
+  ahead=0
+  behind=0
+  if [ "$branch" != "(detached)" ] && git -C "$path" rev-parse '@{u}' >/dev/null 2>&1; then
+    ahead=$(git -C "$path" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
+    behind=$(git -C "$path" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
+  fi
+
+  # Age
+  age="?"
+  if [ -n "$head" ]; then
+    age=$(git log -1 --format='%ar' "$head" 2>/dev/null | sed 's/ ago//')
+  fi
+
+  # Merged into trunk?
+  merged=false
+  if [ -n "$head" ] && [ "$branch" != "$TRUNK" ] && \
+     git rev-parse --verify "$TRUNK" >/dev/null 2>&1 && \
+     git merge-base --is-ancestor "$head" "$TRUNK" 2>/dev/null; then
+    merged=true
+  fi
+
+  # Build state string
+  state=""
+  dirty=false
+  [ "$staged"    -gt 0 ] && state="${state} ${staged}s"    && dirty=true
+  [ "$unstaged"  -gt 0 ] && state="${state} ${unstaged}u"  && dirty=true
+  [ "$untracked" -gt 0 ] && state="${state} ${untracked}?" && dirty=true
+  [ "$ahead"     -gt 0 ] && state="${state} +${ahead}"
+  [ "$behind"    -gt 0 ] && state="${state} -${behind}"
+  state="${state# }"
+  [ -z "$state" ] && state="clean"
+  [ "$merged" = true ] && state="$state (merged)"
+
+  # Verdict
+  if [ "$branch" = "$TRUNK" ]; then
+    verdict="(trunk)"
+  elif [ "$dirty" = true ]; then
+    verdict="has WIP"
+    WIP=$((WIP+1))
+  elif [ "$ahead" -gt 0 ]; then
+    verdict="unpushed"
+    UNPUSHED=$((UNPUSHED+1))
+  elif [ "$merged" = true ]; then
+    verdict="PRUNABLE"
+    PRUNABLE=$((PRUNABLE+1))
+  else
+    verdict="in-flight"
+  fi
+
+  printf "%-40s %-20s %-22s %-12s %s\n" "$disp" "$branch" "$state" "$age" "$verdict"
+done < "$TMP_REG"
+
+# --- Orphans: filesystem entries in .claude/worktrees/ with no registration ---
+while IFS= read -r fs_path; do
+  [ -z "$fs_path" ] && continue
+  registered=false
+  while IFS=$'\t' read -r reg_path _ _; do
+    reg_canon=$( (cd "$reg_path" 2>/dev/null && pwd -P) || echo "$reg_path" )
+    if [ "$reg_canon" = "$fs_path" ]; then
+      registered=true
+      break
+    fi
+  done < "$TMP_REG"
+  if [ "$registered" = false ]; then
+    disp="${fs_path#$REPO_ROOT/}"
+    printf "%-40s %-20s %-22s %-12s %s\n" "$disp" "?" "UNREGISTERED" "?" "manual review (DO NOT touch)"
+    ORPHANS=$((ORPHANS+1))
+  fi
+done < "$TMP_FS"
+
+rm -f "$TMP_REG" "$TMP_FS"
+
+# --- Summary ---
+echo ""
+echo "Summary: $WT_COUNT registered / $FS_COUNT in .claude/worktrees / $ORPHANS orphan"
+echo "  PRUNABLE (merged, clean, linked):   $PRUNABLE"
+echo "  WIP (uncommitted changes):          $WIP"
+echo "  Unpushed (ahead of upstream):       $UNPUSHED"
+echo "  Ghost (registered, FS missing):     $GHOSTS"
+echo "  Orphan (FS exists, unregistered):   $ORPHANS    ← read-only, never rm without review"
+
+# Legend note (shown only if abbreviations appear in output)
+if [ "$WIP" -gt 0 ] || [ "$UNPUSHED" -gt 0 ]; then
+  echo ""
+  echo "  STATE legend: Ns=staged, Nu=unstaged, N?=untracked, +N=ahead, -N=behind"
+fi
+
+# Exit 1 if anything needs attention
+if [ "$GHOSTS" -gt 0 ] || [ "$ORPHANS" -gt 0 ] || [ "$PRUNABLE" -gt 0 ]; then
+  exit 1
+fi
+exit 0