← 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