[object Object]

← back to Terminal Status

stop_verdict: read last_assistant_message + background_tasks from the Stop hook stdin (no transcript flush race); D1 done+time outranks the word parked, not W1 (TK-11921)

c1b81352e354c807aca8e318f4d66340a9aca187 · 2026-09-18 14:57:42 -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 c1b81352e354c807aca8e318f4d66340a9aca187
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 14:57:42 2026 -0700

    stop_verdict: read last_assistant_message + background_tasks from the Stop hook stdin (no transcript flush race); D1 done+time outranks the word parked, not W1 (TK-11921)
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE
---
 integrations/dot-floor.sh |  3 +++
 stop_verdict.py           | 50 +++++++++++++++++++++++++++++++++++++++++++++--
 test_stop_verdict.py      | 33 +++++++++++++++++++++++++++++++
 3 files changed, 84 insertions(+), 2 deletions(-)

diff --git a/integrations/dot-floor.sh b/integrations/dot-floor.sh
index 765f99d..6b80a6e 100644
--- a/integrations/dot-floor.sh
+++ b/integrations/dot-floor.sh
@@ -72,6 +72,9 @@ if [ "${DOT_FLOOR_VERDICT:-1}" = 1 ] && [ -f "$VERDICT" ]; then
     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
diff --git a/stop_verdict.py b/stop_verdict.py
index af8f269..229eb55 100644
--- a/stop_verdict.py
+++ b/stop_verdict.py
@@ -304,6 +304,11 @@ def decide(hits, times, *, base="unknown", base_variant="", base_label="",
             return _out("purple", "set", rule, label="GATED" + AUTO, snip=hits[rule], **meta)
     if "Q1" in hits:
         return _out("yellow", "set", "Q1", label="DIRECTION?" + AUTO, snip=hits["Q1"], **meta)
+    # D1 (done + a clock time) is Steve's strongest signal: it beats the bare word "parked"
+    # in prose (live 2026-09-18: a "Done 14:40" report that mentioned "parked" came out P1).
+    if "D1" in hits and "S2" not in hits and "W1" not in hits:
+        t = times["D1"]
+        return _out("pink", "set", "D1", label="DONE %s" % t + AUTO, snip=hits["D1"], time=t, **meta)
     if "P1" in hits:
         return _out("pink", "set", "P1", label="PARKED" + AUTO, snip=hits["P1"], **meta)
     if "W1" in hits:
@@ -381,6 +386,44 @@ def verdict_from_lines(lines, *, base, base_variant, base_label, idle_pink):
                   idle_pink=idle_pink, spinning_ids=spinning_tasks(records))
 
 
+BG_DONE_STATES = {"completed", "done", "failed", "killed", "cancelled", "canceled", "stopped", "exited"}
+
+
+def live_background_ids(background_tasks):
+    """Ids of still-running tasks from the Stop hook's background_tasks list (any shape)."""
+    ids = []
+    for t in background_tasks or []:
+        if isinstance(t, str):
+            ids.append(t)
+        elif isinstance(t, dict):
+            status = str(t.get("status") or t.get("state") or "").lower()
+            if status and status in BG_DONE_STATES:
+                continue
+            ids.append(str(t.get("id") or t.get("task_id") or t.get("taskId") or "task"))
+    return ids
+
+
+def verdict_from_stdin(stdin, *, base=None, base_variant=None, idle_pink=None):
+    """Prefer the hook's own stdin fields (last_assistant_message, background_tasks) over the
+    transcript: at Stop time the final assistant record is often NOT yet flushed (TK-11921 live
+    miss), whereas stdin always carries the finished message. Returns None if stdin lacks it."""
+    if not isinstance(stdin, dict):
+        return None
+    text = stdin.get("last_assistant_message")
+    if not isinstance(text, str) or not text.strip():
+        return None
+    idle_pink = idle_pink_env() if idle_pink is None else idle_pink
+    if base is None:
+        base, base_variant, base_label = read_base()
+    else:
+        base_variant, base_label = base_variant or "", ""
+    hits, times = match(text, base_variant)
+    d = decide(hits, times, base=base, base_variant=base_variant, base_label=base_label,
+               idle_pink=idle_pink, spinning_ids=live_background_ids(stdin.get("background_tasks")))
+    d["source"] = "stdin"
+    return d
+
+
 def verdict_for_transcript(path, *, base=None, base_variant=None, idle_pink=None):
     idle_pink = idle_pink_env() if idle_pink is None else idle_pink
     if not path or not os.path.isfile(path) or not os.access(path, os.R_OK):
@@ -479,8 +522,11 @@ def main(argv=None):
             stdin = json.loads(sys.stdin.read() or "{}")
         except ValueError:
             stdin = {}
-        path = stdin.get("transcript_path") if isinstance(stdin, dict) else None
-        decision = verdict_for_transcript(path, base=args.base, base_variant=args.variant)
+        decision = verdict_from_stdin(stdin, base=args.base, base_variant=args.variant)
+        if decision is None:
+            path = stdin.get("transcript_path") if isinstance(stdin, dict) else None
+            decision = verdict_for_transcript(path, base=args.base, base_variant=args.variant)
+            decision["source"] = "transcript"
     print(json.dumps(decision, ensure_ascii=False))
     return 0
 
diff --git a/test_stop_verdict.py b/test_stop_verdict.py
index 97c91fe..e0a0bb0 100644
--- a/test_stop_verdict.py
+++ b/test_stop_verdict.py
@@ -300,5 +300,38 @@ class NullTextBlockTest(unittest.TestCase):
         self.assertEqual(d["verdict"], "pink")
         self.assertIn(d["rule"], ("D2", "D3"))
 
+
+class StdinFirstTests(unittest.TestCase):
+    """TK-11921: the Stop hook stdin carries last_assistant_message + background_tasks."""
+
+    def test_message_from_stdin_beats_missing_transcript(self):
+        d = sv.verdict_from_stdin({"last_assistant_message": "All landed.\n\nDone 14:40.",
+                                   "background_tasks": [], "transcript_path": "/nonexistent"},
+                                  base="green", base_variant="", idle_pink=True)
+        self.assertEqual((d["verdict"], d["rule"], d["source"]), ("pink", "D1", "stdin"))
+
+    def test_live_background_task_keeps_green(self):
+        d = sv.verdict_from_stdin({"last_assistant_message": "Kicked off the build; results will land shortly.",
+                                   "background_tasks": [{"id": "b1", "status": "running"}]},
+                                  base="green", base_variant="", idle_pink=True)
+        self.assertEqual((d["verdict"], d["rule"]), ("green", "S1"))
+        d2 = sv.verdict_from_stdin({"last_assistant_message": "Kicked off the build; results will land shortly.",
+                                    "background_tasks": [{"id": "b1", "status": "completed"}]},
+                                   base="green", base_variant="", idle_pink=True)
+        self.assertEqual(d2["rule"], "IDLE")
+
+    def test_done_with_time_beats_parked_word(self):
+        d = sv.verdict_from_stdin({"last_assistant_message": "Done 14:40. The detector scans for done, parked and waiting signals.",
+                                   "background_tasks": []}, base="green", base_variant="", idle_pink=True)
+        self.assertEqual((d["verdict"], d["rule"]), ("pink", "D1"))
+        # ...but a genuine waiting-on-Steve ask in the same message still wins (attention colour)
+        d = sv.verdict_from_stdin({"last_assistant_message": "Done 14:40. Tell me when you've run the paste.",
+                                   "background_tasks": []}, base="green", base_variant="", idle_pink=True)
+        self.assertEqual((d["verdict"], d["rule"]), ("lightblue", "W1"))
+
+    def test_no_message_returns_none(self):
+        self.assertIsNone(sv.verdict_from_stdin({"transcript_path": "/x"}, base="green"))
+        self.assertIsNone(sv.verdict_from_stdin({"last_assistant_message": "   "}, base="green"))
+
 if __name__ == "__main__":
     unittest.main()

← 29097cd auto-data-snapshot: 2026-09-18T14:54:24 (1 data files) — tes  ·  back to Terminal Status  ·  Fix --all broadcast footgun when label precedes --all flag ( 199e156 →