← 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
M integrations/dot-floor.shM stop_verdict.pyM test_stop_verdict.pyA tests/stop-verdict/33-done-first-line-long-report.jsonl
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 →