← 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
M integrations/dot-floor.shM stop_verdict.pyM test_stop_verdict.py
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 →