← back to Terminal Status

integrations/dot-floor.sh

164 lines

#!/usr/bin/env bash
# Stop hook: ONE mechanical dot decision per turn-stop (TK-11921, Steve 2026-09-18:
# "why doesn't done trigger non green dot every time" + "Waiting is its own color dot").
#
# Before TK-11921 this hook only re-emitted the stored colour, added the additive 🛑 marker
# on needs-Steve bases, and caught a trailing "?". Nothing ever CHANGED the colour, so a turn
# that ended with "done" stayed green unless the model ran a dot script by hand — and in
# bridge/leaked sessions even that no-op'd because hooks never passed --boot.
#
# Now: stop_verdict.py reads the closing assistant text + the base record and returns one
# decision (orange paste / purple memo / yellow question / lightblue waiting-on-Steve /
# teal waiting-on-a-check / pink done-parked-idle / green only while a bg task is live).
# This hook dispatches exactly ONE engine call with --boot (the documented own-pane hook
# bypass of the child-session rail), logs the decision, and STILL exits 0.
#
# TK-11369 rationale kept: a non-zero Stop hook banners stderr at nearly every turn-stop
# across ~50 sessions (alarm fatigue). The LOG is the detector; the hook stays quiet.
#
# Seams (never passed by settings.json/plists):
#   --test <fixture.jsonl>   decide on a fixture, print the decision, paint nothing
#   DOT_FLOOR_DRY=1          decide on the real transcript, print, paint nothing
#   HEALTH=<dir>             health/log dir override (negative health test)
#   DOT_FLOOR_FAULT=detector inject a detector fault (must produce WARN)
# Kill-switches: DOT_FLOOR_VERDICT=0 (legacy repaint-only), DOT_FLOOR_IDLE_PINK=0 (no
# idle→pink; read by stop_verdict.py).

STDIN_JSON=$(cat 2>/dev/null || true)
# TK-11921 diagnostic (temporary): record what the Stop hook actually receives, once per run.
[ -n "${DOT_FLOOR_DUMP_STDIN:-}" ] || printf '%s' "$STDIN_JSON" | head -c 20000 > "$HOME/.claude/skills/terminal-status-health/data/last-stop-stdin.json" 2>/dev/null

ENGINE="$HOME/Projects/terminal-status/terminal_status.py"
VERDICT="${DOT_FLOOR_VERDICT_PY:-$HOME/Projects/terminal-status/stop_verdict.py}"   # override = test seam only
HEALTH="${HEALTH:-$HOME/.claude/skills/terminal-status-health/data}"
LOG="$HEALTH/repaint-failures.jsonl"
DECLOG="$HEALTH/stop-decisions.jsonl"
LATEST="$HEALTH/latest.json"
TO=$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)
# Bounded runners as ARRAYS: a bare "$TO 15 cmd" with TO="" would execute "15" (exit 127).
# Budget: 10 s detector + 35 s engine = 45 s inside Claude Code's 60 s hook ceiling.
DET_RUN=(); ENG_RUN=(); [ -n "$TO" ] && { DET_RUN=("$TO" 10); ENG_RUN=("$TO" 35); }
t0=$(date +%s)
ERRF=$(mktemp "${TMPDIR:-/tmp}/dot-floor.err.XXXXXX" 2>/dev/null || echo "/tmp/dot-floor.err.$$"); trap 'rm -f "$ERRF"' EXIT

TEST_FIXTURE=""; DRY="${DOT_FLOOR_DRY:-0}"
if [ "${1:-}" = "--test" ]; then TEST_FIXTURE="${2:-}"; DRY=1; fi

rotate() { local f="$1" sz; sz=$(stat -f%z "$f" 2>/dev/null || echo 0); [ "$sz" -gt 262144 ] && mv -f "$f" "$f.1" 2>/dev/null; :; }
json_str() { printf '%s' "$1" | tr -d '\000-\037"\\' | cut -c1-"${2:-300}"; }   # strips ALL control bytes, not just \n\r
write_latest() { # verdict headline  (never soften an existing FAIL with a WARN)
  local v="$1" h="$2" cur=""
  [ -f "$LATEST" ] && cur=$(grep -o '"verdict":"[A-Z]*"' "$LATEST" | head -1)
  if [ "$v" = WARN ] && [ "$cur" = '"verdict":"FAIL"' ]; then return; fi
  mkdir -p "$HEALTH" 2>/dev/null
  printf '{"generated_at":"%s","verdict":"%s","status":"%s","headline":"%s"}\n' \
    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$v" "$v" "$(json_str "$h" 200)" > "$LATEST.tmp.$$" && mv -f "$LATEST.tmp.$$" "$LATEST"
}
log_real() { # rc err
  mkdir -p "$HEALTH" 2>/dev/null; rotate "$LOG"
  printf '{"ts":"%s","pid":%s,"rc":%s,"err":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$1" "$(json_str "$2")" >> "$LOG"
  write_latest FAIL "terminal-status paint failed: $(json_str "$2" 120)"
}

# --- 1. decide -----------------------------------------------------------------------------
action=repaint; color=""; variant=""; label=""; rule=""; snippet=""; spinning=""; verdict=""; base=""; det_rc=0; det_err=""
if [ "${DOT_FLOOR_VERDICT:-1}" = 1 ] && [ -f "$VERDICT" ]; then
  if [ -n "$TEST_FIXTURE" ]; then
    decision=$("${DET_RUN[@]}" python3 "$VERDICT" --test "$TEST_FIXTURE" 2>"$ERRF"); det_rc=$?
  else
    # The final assistant record is often NOT yet flushed to the transcript when Stop fires
    # (TK-11921 live evidence: rule NO_TEXT on a turn that ended "Done 14:33"). Retry briefly.
    attempts=0
    while :; do
      attempts=$((attempts+1))
      decision=$(printf '%s' "$STDIN_JSON" | "${DET_RUN[@]}" python3 "$VERDICT" 2>"$ERRF"); det_rc=$?
      # Retry only when the verdict came from the transcript (stdin's last_assistant_message,
      # when present, is already the finished message — nothing to wait for).
      case "$decision" in *'"source": "stdin"'*|*'"source":"stdin"'*) break ;; esac
      case "$decision" in *'"rule": "NO_TEXT"'*|*'"rule":"NO_TEXT"'*) [ "$attempts" -lt "${DOT_FLOOR_RETRIES:-4}" ] && { sleep "${DOT_FLOOR_RETRY_SLEEP:-0.75}"; continue; } ;; esac
      break
    done
  fi
  det_err=$(grep -E '[A-Za-z]*(Error|Exception)' "$ERRF" 2>/dev/null | tail -1); [ -z "$det_err" ] && det_err=$(tail -1 "$ERRF" 2>/dev/null)
  [ "$det_rc" -eq 124 ] && det_err="detector timeout (124)"
  if [ "$det_rc" -eq 0 ] && [ -n "$decision" ]; then
    # one python call → fields joined by \x1f (unit separator). NOT tab: tab is IFS
    # whitespace, so an EMPTY field (variant="", snippet="") would collapse and shift
    # every later field. A non-whitespace IFS char keeps empty fields in place.
    IFS=$'\x1f' read -r action color variant label rule snippet spinning verdict base <<EOF
$(printf '%s' "$decision" | python3 -c '
import sys,json
try: d=json.load(sys.stdin)
except Exception: d={}
def f(k,dflt=""):
    v=d.get(k,dflt); v=",".join(v) if isinstance(v,list) else ("" if v is None else str(v))
    return v.replace("\x1f"," ").replace("\t"," ").replace("\n"," ")
print("\x1f".join([f("action","repaint"),f("verdict"),f("variant"),f("label"),f("rule"),f("snippet")[:80],f("spinning"),f("verdict"),f("base")]))' 2>/dev/null)
EOF
    [ -z "$action" ] && action=repaint
  else
    action=repaint; rule="DETECTOR_ERROR"
    decision=$(printf '{"verdict":"none","action":"repaint","rule":"DETECTOR_ERROR","det_rc":%s,"error":"%s"}' "$det_rc" "$(json_str "$det_err" 120)")
  fi
fi

# --- 2. paint (exactly one engine call, --boot = own-pane hook bypass of the child rail) ----
rc=0; err=""
if [ "$DRY" = 1 ]; then
  echo "$decision"
else
  case "$action" in
    set)
      args=(set "$color" "$label"); [ "$variant" = monitoring ] && args+=(--monitoring)
      err=$("${ENG_RUN[@]}" python3 "$ENGINE" "${args[@]}" --boot --quiet 2>&1 >/dev/null); rc=$? ;;
    set-variant)
      err=$("${ENG_RUN[@]}" python3 "$ENGINE" set-variant stopped --boot 2>&1 >/dev/null); rc=$? ;;
    none) : ;;
    *)
      err=$("${ENG_RUN[@]}" python3 "$ENGINE" repaint --boot --quiet 2>&1 >/dev/null); rc=$? ;;
  esac
fi

# --- 3. classify engine failure (benign list = enumerated routine StatusErrors; the
#        child-session refusal is NO LONGER benign — with --boot it must not happen) ---------
# DRY/--test mode paints nothing and writes NO health state either (a fault fixture must not
# WARN the real panel unless HEALTH= points at a scratch dir, which the negative test does).
[ "$DRY" = 1 ] && [ "$HEALTH" = "$HOME/.claude/skills/terminal-status-health/data" ] && exit 0
if [ "$rc" -ne 0 ]; then
  case "$err" in
    *"unknown status"*|*"subagent has no terminal"*|*"No owning Claude"*|\
    *"Headless or nested"*|*"owner changed"*|\
    *"busy; retry"*|*"Obsolete status update"*|*"No unique live main agent"*|\
    *"Resource temporarily unavailable"*|*"Errno 35"*|\
    *"Invalid status or label"*|*"Expected a canonical TK number"*|\
    *"Ticket does not exist"*|*"Unknown legacy semantic color"*|\
    *"set an explicit color"*) class=benign ;;
    *) class=real ;;
  esac
  [ "$rc" -eq 124 ] && { class=real; err="engine timeout (124) during $action"; }
  [ "$class" = real ] && log_real "$rc" "$err"
fi

# --- 4. health: detector fault → WARN (never softens FAIL); 24h clean → PASS ---------------
if [ "$det_rc" -ne 0 ]; then
  write_latest WARN "stop-verdict detector error rc=$det_rc: $(json_str "$det_err" 120)"
elif [ "$rc" -eq 0 ] && [ -f "$LATEST" ] && grep -qE '"verdict":"(FAIL|WARN)"' "$LATEST" 2>/dev/null; then
  now=$(date +%s); mt=$(stat -f %m "$LATEST" 2>/dev/null || echo "$now")
  [ "$(( now - mt ))" -gt 86400 ] && write_latest PASS "terminal-status stop hook healthy (24h clear)"
fi

# --- 5. decision log: the answer to "why didn't that tab flip" -----------------------------
if [ "$DRY" != 1 ]; then
  mkdir -p "$HEALTH" 2>/dev/null; rotate "$DECLOG"
  sid=$(printf '%s' "$STDIN_JSON" | python3 -c 'import sys,json
try: d=json.load(sys.stdin); print(d.get("session_id",""), d.get("stop_hook_active",""))
except Exception: print("", "")' 2>/dev/null)
  printf '{"ts":"%s","pid":%s,"session":"%s","stop_hook_active":"%s","tty":"%s","base":"%s","verdict":"%s","action":"%s","variant":"%s","rule":"%s","snippet":"%s","spinning":"%s","label":"%s","rc":%s,"det_rc":%s,"attempts":%s,"secs":%s}\n' \
    "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "${sid%% *}" "${sid#* }" "$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ')" \
    "$(json_str "$base" 20)" "$(json_str "$verdict" 20)" "$(json_str "$action" 20)" "$(json_str "$variant" 20)" \
    "$(json_str "$rule" 30)" "$(json_str "$snippet" 80)" "$(json_str "$spinning" 30)" "$(json_str "$label" 80)" \
    "$rc" "$det_rc" "${attempts:-1}" "$(( $(date +%s) - t0 ))" >> "$DECLOG"
fi

exit 0