← back to Terminal Status
integrations: sync dot-floor.sh with the live TK-11921 Stop hook (install.py deploys this copy)
ec7cc196f9744d1cf2e58e294bab6e1e6f81fb09 · 2026-09-18 14:32:18 -0700 · Steve Abrams
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE
Files touched
M integrations/dot-floor.sh
Diff
commit ec7cc196f9744d1cf2e58e294bab6e1e6f81fb09
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 18 14:32:18 2026 -0700
integrations: sync dot-floor.sh with the live TK-11921 Stop hook (install.py deploys this copy)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE
---
integrations/dot-floor.sh | 152 +++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 149 insertions(+), 3 deletions(-)
diff --git a/integrations/dot-floor.sh b/integrations/dot-floor.sh
index 1de2cbf..fb12d38 100644
--- a/integrations/dot-floor.sh
+++ b/integrations/dot-floor.sh
@@ -1,4 +1,150 @@
#!/usr/bin/env bash
-# Stop hooks preserve deliberate status. Unknown is never guessed green.
-cat >/dev/null 2>&1 || true
-python3 "$HOME/Projects/terminal-status/terminal_status.py" repaint --quiet || true
+# 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)
+
+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
+ decision=$(printf '%s' "$STDIN_JSON" | "${DET_RUN[@]}" python3 "$VERDICT" 2>"$ERRF"); det_rc=$?
+ 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,"ms":%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" "$(( ($(date +%s) - t0) * 1000 ))" >> "$DECLOG"
+fi
+
+exit 0
← f5399f8 stop_verdict: mechanical done/waiting dot verdict for the St
·
back to Terminal Status
·
stop_verdict: skip null/non-string text blocks (uncaught Att 24de799 →