[object Object]

← back to Terminal Status

stop_verdict: scan the WHOLE closing message for done/wait/park signals (Q1 stays last-line); hook retries while the final assistant record flushes (TK-11921 live miss: 'Done 14:33' → NO_TEXT then IDLE)

f3313d00dfbd2f26120bf2e42f3da239052a781e · 2026-09-18 14:40:16 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE

Files touched

Diff

commit f3313d00dfbd2f26120bf2e42f3da239052a781e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 14:40:16 2026 -0700

    stop_verdict: scan the WHOLE closing message for done/wait/park signals (Q1 stays last-line); hook retries while the final assistant record flushes (TK-11921 live miss: 'Done 14:33' → NO_TEXT then IDLE)
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE
---
 integrations/dot-floor.sh                               | 16 +++++++++++++---
 stop_verdict.py                                         | 17 +++++++++--------
 test_stop_verdict.py                                    | 11 +++++++++--
 tests/stop-verdict/33-done-first-line-long-report.jsonl |  3 +++
 4 files changed, 34 insertions(+), 13 deletions(-)

diff --git a/integrations/dot-floor.sh b/integrations/dot-floor.sh
index fb12d38..765f99d 100644
--- a/integrations/dot-floor.sh
+++ b/integrations/dot-floor.sh
@@ -25,6 +25,8 @@
 # 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
@@ -64,7 +66,15 @@ 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=$?
+    # 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=$?
+      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)"
@@ -140,11 +150,11 @@ if [ "$DRY" != 1 ]; then
   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' \
+  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" "$(( ($(date +%s) - t0) * 1000 ))" >> "$DECLOG"
+    "$rc" "$det_rc" "${attempts:-1}" "$(( $(date +%s) - t0 ))" >> "$DECLOG"
 fi
 
 exit 0
diff --git a/stop_verdict.py b/stop_verdict.py
index b575dd5..af8f269 100644
--- a/stop_verdict.py
+++ b/stop_verdict.py
@@ -216,7 +216,8 @@ def match(text, base_variant=""):
     hits, times = {}, {}
     raw = text
     clean = clean_view(raw)
-    win = window(clean)
+    win = window(clean)   # Q1 only: the question test looks at the LAST line
+    # every other text signal scans the WHOLE closing message (Steve: "done ALWAYS leaves green")
 
     m = O1.search(raw)
     if m:
@@ -224,7 +225,7 @@ def match(text, base_variant=""):
     m = U1.search(raw)
     if m:
         hits["U1"] = raw[max(0, m.start() - 40):m.end() + 40]
-    m = U2.search(win)
+    m = U2.search(clean)
     if m:
         hits["U2"] = m.group(0)
 
@@ -233,23 +234,23 @@ def match(text, base_variant=""):
         hits["Q1"] = last[-1]
 
     for rule, rx in (("W1", W1), ("W2", W2)):
-        for m in rx.finditer(win):
-            if not WAIT_NEGATION.search(win[:m.start()]):
+        for m in rx.finditer(clean):
+            if not WAIT_NEGATION.search(clean[max(0, m.start() - 60):m.start()]):
                 hits[rule] = m.group(0)
                 break
-    m = P1.search(win)
+    m = P1.search(clean)
     if m:
         hits["P1"] = m.group(0)
-    m = S2.search(win)
+    m = S2.search(clean)
     if m:
         hits["S2"] = m.group(0)
-        t = S2_TIME.search(win)
+        t = S2_TIME.search(clean)
         if t:
             times["S2"] = t.group(1)
     if base_variant == "monitoring":
         hits["S3"] = "base variant monitoring"
 
-    guarded = guarded_text(win)
+    guarded = guarded_text(clean)
     m = D1_A.search(guarded) or D1_B.search(guarded)
     if m:
         hits["D1"] = m.group(0)
diff --git a/test_stop_verdict.py b/test_stop_verdict.py
index d20dba4..97c91fe 100644
--- a/test_stop_verdict.py
+++ b/test_stop_verdict.py
@@ -102,11 +102,18 @@ class MatchTests(unittest.TestCase):
         self.assertIn("S3", hits)
         self.assertNotIn("S3", sv.match("Still importing.")[0])
 
-    def test_window_is_last_six_clean_lines(self):
+    def test_done_anywhere_in_closing_message(self):
+        # Steve: "the word done ALWAYS leaves green" — a done-signal on the FIRST line of a long
+        # report counts (live miss 2026-09-18: "Done 14:33" + 20 lines came out IDLE).
         text = "Done.\n" + "\n".join("line %d" % i for i in range(6))
-        self.assertNotIn("D2", sv.match(text)[0])
+        self.assertIn("D2", sv.match(text)[0])
         self.assertIn("D2", sv.match("\n".join("line %d" % i for i in range(5)) + "\nDone.")[0])
 
+    def test_question_is_last_line_only(self):
+        text = "Is this right?\n" + "\n".join("line %d" % i for i in range(6))
+        self.assertNotIn("Q1", sv.match(text)[0])
+        self.assertIn("Q1", sv.match("line 1\nline 2\nIs this right?")[0])
+
     def test_clean_view_strips_fences_code_tables_rules_emphasis(self):
         raw = "```\n! paste\n```\n`tk done`\n| a | done |\n─────\n★ Insight ─────\n**Done** _now_"
         self.assertEqual(sv.clean_view(raw), "Done now")
diff --git a/tests/stop-verdict/33-done-first-line-long-report.jsonl b/tests/stop-verdict/33-done-first-line-long-report.jsonl
new file mode 100644
index 0000000..5fc35be
--- /dev/null
+++ b/tests/stop-verdict/33-done-first-line-long-report.jsonl
@@ -0,0 +1,3 @@
+{"type":"fixture","base":"green","variant":"","idle_pink":true,"expect":{"verdict":"pink","rule":"D1","label":"DONE 14:33 ·auto"}}
+{"type":"user","message":{"role":"user","content":"yes and test for coding errors"},"isSidechain":false}
+{"type":"assistant","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"**Done 14:33.** The tab dot is now painted mechanically on every turn-stop.\n\n**Why it never flipped** (two independent bugs):\n\n- No done rule existed in code. The Stop hook only re-emitted the stored colour.\n- In bridge sessions the hooks could not paint at all.\n\n**What shipped:**\n\n- stop_verdict.py decides once per Stop.\n- The Stop hook paints that one verdict with --boot.\n- 35 unit tests and 37 fixtures.\n\nStill yours from earlier: the OpenAI spend limit and Claude cap pastes, the flyers deploy paste in the other tab, and the pm2-resurrect memo."}]}}

← b5b2887 test_stop_verdict: use the sv alias in the null-text regress  ·  back to Terminal Status  ·  auto-data-snapshot: 2026-09-18T14:54:24 (1 data files) — tes 29097cd →