Browse Source

refactor(skills/fleet-ops): grouped-tree as default body layout

The flat table was hiding the answer in plain sight — with ten lanes
in flight, "what's ready to land?" still required eye-filtering. Switch
fleet's default view to grouped-by-state with tree connectors:

  ── fleet ──────────────  4 lanes · 3 active

    ⏳ RUNNING   (2)
      ├─ feat/auth-rewrite             12m
      └─ spike/wasm-eval               34m

    ✅ READY     (1)
      └─ fix/cache-bust                2m

The horizontal-line "app header" stays — it's the strongest cue that
you're inside a skill's output. Empty groups are omitted, never (0).

Plumbing:
- term.sh gains TERM_TREE_BRANCH/LAST/VERT (├─ └─ │ / +- `- |) and
  helpers term_group_header, term_tree_branch, term_tree_last,
  term_tree_connector.
- DESIGN.md elevates "rule + grouped tree" as the default; flat table
  becomes the escape hatch for genuinely flat data (status checks).
- fleet-ops e2e suite still passes (20/20) in both Unicode and ASCII.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 month ago
parent
commit
0011cedb4d
3 changed files with 172 additions and 30 deletions
  1. 60 17
      docs/DESIGN.md
  2. 44 0
      skills/_lib/term.sh
  3. 68 13
      skills/fleet-ops/scripts/fleet.sh

+ 60 - 17
docs/DESIGN.md

@@ -58,28 +58,60 @@ Use sparingly — borders that wrap nothing waste lines.
 
 ## Layouts
 
-### Header block
+The default layout is **rule + grouped tree**: a horizontal-line "app
+header" on top, then items grouped by state with tree connectors. Flat
+tables are reserved for one-row-per-thing data where grouping would just
+add noise.
 
-A header opens a logical section. Title is cyan; trailing meta is dim.
+### Header rule (the "app header")
+
+Always present. Title in cyan, trailing meta in dim. The rule extends to
+terminal width so the header reads as the section's banner.
 
 ```
-── Fleet ──────────────────────────────────────────────────────  3 lanes
+── fleet ─────────────────────────────────────────────────────  4 lanes · 3 active
 ```
 
-### Status table
+### Grouped tree (default body)
 
-Two- or three-column, glyph-first. No nested tables.
+State icon + group label + count, then children under tree connectors.
+Empty groups are omitted — never render a group with `(0)`.
 
 ```
-  ⏳  feat/auth-rewrite             RUNNING    12m
-  ✅  fix/cache-bust                READY      2m
-  🚀  chore/bump-deps               LANDED     1h
-  ❌  spike/wasm                    FAILED     34m
+── fleet ─────────────────────────────────────────────────────  4 lanes · 3 active
+
+  ⏳ RUNNING   (2)
+    ├─ feat/auth-rewrite             12m
+    └─ spike/wasm-eval               34m
+
+  ✅ READY     (1)
+    └─ fix/cache-bust                2m
+
+  🚀 LANDED    (1)
+    └─ chore/bump-deps               1h
 ```
 
-### Tree
+Why grouped instead of flat: when ten lanes are in flight, scanning a
+flat table for "what's actually ready to land?" forces your eyes to do
+the filtering. Grouping does it for you, and the count tells you at a
+glance whether the answer is none, one, or twelve.
 
-For hierarchical state — worktrees under a repo, files under a branch.
+### Flat status table (escape hatch)
+
+When the data is genuinely flat — `git status`-style fields, a single
+PR's checks — drop the tree. Glyph-first, no nested tables.
+
+```
+  ✅  secret scan        clean
+  ✅  forbidden files    none
+  ❌  divergence         3 ahead, 1 behind
+```
+
+### Plain tree (filesystem-style, no state grouping)
+
+For hierarchies that aren't keyed on state — a directory tree, an
+include graph. Use sparingly; the grouped-tree above is preferred for
+state.
 
 ```
 repo/
@@ -122,7 +154,7 @@ Disabled when stdout isn't a TTY, or `NO_COLOR` is set. Forced on with
 
 ## Examples (rendered)
 
-### Before — `fleet-ops` rolling its own
+### Before — `fleet-ops` rolling its own (flat table, double rules)
 
 ```
 ── Fleet ──────────────────────────────────────────────────────
@@ -130,18 +162,29 @@ Disabled when stdout isn't a TTY, or `NO_COLOR` is set. Forced on with
 ────────────────────────────────────────────────────────────────
   ⏳   feat/auth-rewrite                 RUNNING    12m
   ✅   fix/cache-bust                    READY      2m
+  🚀   chore/bump-deps                   LANDED     1h
 ────────────────────────────────────────────────────────────────
 ```
 
-### After — same skill, sourcing `_lib/term.sh`
+### After — rule on top, grouped tree as default
 
 ```
-── Fleet ──────────────────────────────────────────────────────  2 lanes
-  ⏳   feat/auth-rewrite                 RUNNING    12m
-  ✅   fix/cache-bust                    READY      2m
+── fleet ─────────────────────────────────────────────────────  3 lanes · 2 active
+
+  ⏳ RUNNING   (1)
+    └─ feat/auth-rewrite             12m
+
+  ✅ READY     (1)
+    └─ fix/cache-bust                2m
+
+  🚀 LANDED    (1)
+    └─ chore/bump-deps               1h
 ```
 
-The chrome shrinks; the glyph and meta carry the structure.
+The header rule survives — it's the strongest visual cue that you're
+inside a skill's output. The flat table gives way to grouped state, so
+"what's ready" and "what's still running" answer themselves before you
+read a single branch name.
 
 ### `git-ops/status` reformatted in the same language
 

+ 44 - 0
skills/_lib/term.sh

@@ -70,6 +70,9 @@ term_init() {
     TERM_ICON_FAILED="[x]"
     TERM_ICON_WARN="[!]"
     TERM_ICON_HINT="[i]"
+    TERM_TREE_BRANCH="+-"
+    TERM_TREE_LAST="\`-"
+    TERM_TREE_VERT="|"
   else
     TERM_ICON_PENDING="⏳"
     TERM_ICON_READY="✅"
@@ -77,6 +80,9 @@ term_init() {
     TERM_ICON_FAILED="❌"
     TERM_ICON_WARN="⚠️ "
     TERM_ICON_HINT="💡"
+    TERM_TREE_BRANCH="├─"
+    TERM_TREE_LAST="└─"
+    TERM_TREE_VERT="│"
   fi
 
   if [[ "$TERM_COLOR" -eq 1 ]]; then
@@ -158,6 +164,44 @@ term_tree_item() {
   fi
 }
 
+# Tree connectors — set by term_init via TERM_ASCII_MODE.
+TERM_TREE_BRANCH=""    # ├─  /  +-
+TERM_TREE_LAST=""      # └─  /  `-
+TERM_TREE_VERT=""      # │   /  |
+
+# term_group_header <icon> <LABEL> <count>  — "  ⏳ RUNNING   (3)"
+# Use as the parent line above term_tree_branch / term_tree_last children.
+term_group_header() {
+  local icon=$1 label=$2 count=$3
+  printf '  %s %-9s %s\n' "$icon" "$label" "$(term_color dim "($count)")"
+}
+
+# term_tree_branch <label> [meta]  — "    ├─ label                meta"
+term_tree_branch() {
+  local label=$1 meta=${2:-}
+  if [[ -n "$meta" ]]; then
+    printf '    %s %-32s %s\n' "$TERM_TREE_BRANCH" "$label" "$(term_color dim "$meta")"
+  else
+    printf '    %s %s\n' "$TERM_TREE_BRANCH" "$label"
+  fi
+}
+
+# term_tree_last <label> [meta]  — "    └─ label                meta"
+term_tree_last() {
+  local label=$1 meta=${2:-}
+  if [[ -n "$meta" ]]; then
+    printf '    %s %-32s %s\n' "$TERM_TREE_LAST" "$label" "$(term_color dim "$meta")"
+  else
+    printf '    %s %s\n' "$TERM_TREE_LAST" "$label"
+  fi
+}
+
+# term_tree_connector <idx> <last_idx>  — echo branch or last, for loops.
+term_tree_connector() {
+  if [[ "$1" -eq "$2" ]]; then printf '%s' "$TERM_TREE_LAST"
+  else printf '%s' "$TERM_TREE_BRANCH"; fi
+}
+
 # term_table_row <c1> <c2> <c3>  — fixed-width 3-col row.
 term_table_row() {
   printf '  %-2s  %-32s %-10s %s\n' "${1:-}" "${2:-}" "${3:-}" "${4:-}"

+ 68 - 13
skills/fleet-ops/scripts/fleet.sh

@@ -119,32 +119,87 @@ cmd_init() {
   echo "Then: bash $0 start"
 }
 
+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
+}
+
 cmd_fleet() {
   ensure_fleet_dir
-  local count=0
-  for f in "$LANES_DIR"/*; do [[ -f "$f" ]] && count=$((count+1)); done
 
-  echo ""
-  term_header "Fleet" "$count $([ "$count" -eq 1 ] && echo lane || echo lanes)"
-  term_table_row "" "BRANCH" "STATUS" "AGE"
+  # Bucket lanes by state. ASCII-safe assoc-array alternative: parallel arrays.
+  local order=(RUNNING READY CONFLICT FAILED LANDED)
+  local now total=0 active=0
+  now=$(date +%s)
+
+  # state_buckets[i] = newline-joined "branch|age|meta" rows for order[i]
+  local state_buckets=("" "" "" "" "")
+  local state_counts=(0 0 0 0 0)
 
-  local now=$(date +%s)
   for f in "$LANES_DIR"/*; do
     [[ -f "$f" ]] || continue
-    local branch state mtime secs age icon
+    total=$((total+1))
+    local branch state meta mtime secs age idx
     branch=$(basename "$f")
     state=$(head -n1 "$f")
+    meta=$(sed -n '2p' "$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
+    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
+
+  echo ""
+  term_header "fleet" "$total $([ "$total" -eq 1 ] && echo lane || echo lanes) · $active active"
+
+  if [[ $total -eq 0 ]]; then
+    echo ""
+    term_empty "no lanes — run: fleet init <name>..."
+    echo ""
+    return
+  fi
+
+  local i
+  for i in 0 1 2 3 4; do
+    local n=${state_counts[$i]}
+    [[ $n -eq 0 ]] && continue
+    local state=${order[$i]}
+    local icon
     icon=$(term_state_icon "$state")
     [[ -z "$icon" || "$icon" == "?" ]] && icon="$ICON_UNKNOWN"
-    term_table_row "$icon" "$branch" "$state" "$age"
+    echo ""
+    term_group_header "$icon" "$state" "$n"
+
+    local lines="${state_buckets[$i]}"
+    local idx=0 last_idx=$((n - 1))
+    local branch age meta
+    while IFS='|' read -r branch age meta; do
+      [[ -z "$branch" ]] && continue
+      local connector
+      connector=$(term_tree_connector "$idx" "$last_idx")
+      local label="$branch"
+      local meta_str="$age"
+      [[ -n "$meta" ]] && meta_str="$age  $meta"
+      printf '    %s %-32s %s\n' "$connector" "$label" "$(term_color dim "$meta_str")"
+      idx=$((idx+1))
+    done <<< "$lines"
   done
-  [[ $count -eq 0 ]] && term_empty "no lanes — run: fleet init <name>..."
+  echo ""
 }
 
 cmd_scrub_check() {