From 594420055ae2ad688e837faafc3c8b5103afe760 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 25 Sep 2026 03:47:03 +0000 Subject: [PATCH 1/5] =?UTF-8?q?invoker:=20wf-1789406909517-39/implement-ho?= =?UTF-8?q?ok-frustration-watchdog=20=E2=80=94=20Put=20the=20frustration-w?= =?UTF-8?q?atchdog=20hook=20onto=20the=20shared=20hook=20code.=20Review=20?= =?UTF-8?q?claim:=20This=20hook=20reports=20findings=20to=20the=20shared?= =?UTF-8?q?=20hook=20code,=20which=20applies=20its=20registry=20mode=20and?= =?UTF-8?q?=20writes=20event=20rows.=20It=20keeps=20mode=20stop.=20Review?= =?UTF-8?q?=20lane:=20behavior=20Safety=20invariant:=20The=20hook=20gives?= =?UTF-8?q?=20the=20same=20stop,=20warn,=20or=20silent=20result=20on=20eve?= =?UTF-8?q?ry=20case=20in=20its=20current=20test=20folder,=20except=20the?= =?UTF-8?q?=20mode=20change=20named=20in=20this=20claim,=20and=20its=20tes?= =?UTF-8?q?t=20folder=20keeps=20exiting=200.=20Effectiveness=20measurement?= =?UTF-8?q?:=20`python3=20-m=20unittest=20discover=20-s=20engine/hooks/fru?= =?UTF-8?q?stration-watchdog/tests`=20exits=200,=20and=20the=20new=20mode-?= =?UTF-8?q?override=20case=20fails=20before=20this=20change.=20Slice=20rat?= =?UTF-8?q?ionale:=20One=20hook=20per=20workflow,=20as=20the=20user=20aske?= =?UTF-8?q?d,=20so=20each=20migration=20is=20reviewed=20on=20its=20own.=20?= =?UTF-8?q?Architectural=20effect:=20The=20frustration-watchdog=20entry=20?= =?UTF-8?q?scripts=20become=20thin=20calls=20into=20the=20shared=20runtime?= =?UTF-8?q?;=20its=20detection=20returns=20findings.=20Goal:=20Stops=20a?= =?UTF-8?q?=20reply=20that=20leaves=20an=20impatient=20user=20waiting.=20K?= =?UTF-8?q?eep=20that=20behavior=20while=20its=20mode=20moves=20into=20the?= =?UTF-8?q?=20registry.=20Motivation:=20Mode=20and=20output=20shape=20live?= =?UTF-8?q?=20inside=20each=20hook=20today;=20the=20shared=20code=20makes?= =?UTF-8?q?=20a=20mode=20change=20a=20one-line=20registry=20edit.=20Altern?= =?UTF-8?q?ative=20considerations:=20Migrating=20several=20hooks=20per=20w?= =?UTF-8?q?orkflow=20was=20set=20aside=20because=20the=20user=20asked=20fo?= =?UTF-8?q?r=20one=20hook=20per=20workflow.=20Implementation=20details:=20?= =?UTF-8?q?Turn=20this=20hook's=20detection=20into=20detect(event)=20retur?= =?UTF-8?q?ning=20Finding=20objects=20with=20stable=20rule=20ids,=20and=20?= =?UTF-8?q?make=20each=20harness=20entry=20script=20call=20run=5Fhook=20fr?= =?UTF-8?q?om=20engine/hooks/=5Fsdk/runtime.py.=20It=20keeps=20mode=20stop?= =?UTF-8?q?.=20Non-goals:=20No=20change=20to=20what=20the=20hook=20detects?= =?UTF-8?q?.=20No=20other=20hook=20changes.=20Layer:=20domain=20Feature=20?= =?UTF-8?q?state:=20active=20Files:=20engine/hooks/frustration-watchdog/cl?= =?UTF-8?q?aude=5Fstop=5Fcheck.py,=20engine/hooks/frustration-watchdog/ins?= =?UTF-8?q?tall=5Fclaude=5Fhook.py,=20engine/hooks/frustration-watchdog/te?= =?UTF-8?q?sts/test=5Fhooks=5Fsdk=5Fmode.py=20Change=20types:=20-=20engine?= =?UTF-8?q?/hooks/frustration-watchdog/claude=5Fstop=5Fcheck.py:=20modify?= =?UTF-8?q?=20-=20engine/hooks/frustration-watchdog/install=5Fclaude=5Fhoo?= =?UTF-8?q?k.py:=20modify=20-=20engine/hooks/frustration-watchdog/tests/te?= =?UTF-8?q?st=5Fhooks=5Fsdk=5Fmode.py:=20create=20Acceptance=20criteria:?= =?UTF-8?q?=20-=20`python3=20-m=20unittest=20discover=20-s=20engine/hooks/?= =?UTF-8?q?frustration-watchdog/tests`=20exits=200.=20-=20With=20CATSTACK?= =?UTF-8?q?=5FHOOK=5FMODE=5FFRUSTRATION=5FWATCHDOG=20set=20to=20warn,=20a?= =?UTF-8?q?=20case=20that=20stops=20today=20produces=20a=20warning=20inste?= =?UTF-8?q?ad,=20proving=20the=20registry=20mode=20drives=20the=20response?= =?UTF-8?q?.=20-=20Each=20finding=20writes=20one=20event=20row=20with=20th?= =?UTF-8?q?e=20hook's=20rule=5Fid.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solution: Put the frustration-watchdog hook onto the shared hook code. Review claim: This hook reports findings to the shared hook code, which applies its registry mode and writes event rows. It keeps mode stop. Review lane: behavior Safety invariant: The hook gives the same stop, warn, or silent result on every case in its current test folder, except the mode change named in this claim, and its test folder keeps exiting 0. Effectiveness measurement: `python3 -m unittest discover -s engine/hooks/frustration-watchdog/tests` exits 0, and the new mode-override case fails before this change. Slice rationale: One hook per workflow, as the user asked, so each migration is reviewed on its own. Architectural effect: The frustration-watchdog entry scripts become thin calls into the shared runtime; its detection returns findings. Goal: Stops a reply that leaves an impatient user waiting. Keep that behavior while its mode moves into the registry. Motivation: Mode and output shape live inside each hook today; the shared code makes a mode change a one-line registry edit. Alternative considerations: Migrating several hooks per workflow was set aside because the user asked for one hook per workflow. Implementation details: Turn this hook's detection into detect(event) returning Finding objects with stable rule ids, and make each harness entry script call run_hook from engine/hooks/_sdk/runtime.py. It keeps mode stop. Non-goals: No change to what the hook detects. No other hook changes. Layer: domain Feature state: active Files: engine/hooks/frustration-watchdog/claude_stop_check.py, engine/hooks/frustration-watchdog/install_claude_hook.py, engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py Change types: - engine/hooks/frustration-watchdog/claude_stop_check.py: modify - engine/hooks/frustration-watchdog/install_claude_hook.py: modify - engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py: create Acceptance criteria: - `python3 -m unittest discover -s engine/hooks/frustration-watchdog/tests` exits 0. - With CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG set to warn, a case that stops today produces a warning instead, proving the registry mode drives the response. - Each finding writes one event row with the hook's rule_id. Invoker-Finalize-Id: 59b37dd6-33c3-4409-af31-15439a541b51 --- .../frustration-watchdog/claude_stop_check.py | 69 +++++++++----- .../frustration-watchdog/codex_stop_check.py | 20 +++++ .../frustration-watchdog/cursor_stop_check.py | 20 +++++ .../tests/test_hooks_sdk_mode.py | 90 +++++++++++++++++++ 4 files changed, 177 insertions(+), 22 deletions(-) create mode 100644 engine/hooks/frustration-watchdog/codex_stop_check.py create mode 100644 engine/hooks/frustration-watchdog/cursor_stop_check.py create mode 100644 engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py diff --git a/engine/hooks/frustration-watchdog/claude_stop_check.py b/engine/hooks/frustration-watchdog/claude_stop_check.py index 89a01493f..f2610d53e 100755 --- a/engine/hooks/frustration-watchdog/claude_stop_check.py +++ b/engine/hooks/frustration-watchdog/claude_stop_check.py @@ -34,8 +34,15 @@ import os import re import sys +import hashlib from datetime import datetime +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from finding import Finding # noqa: E402 +from runtime import run_hook # noqa: E402 + IMPATIENCE_PATTERNS = [ ("profanity", re.compile(r"\b(fuck\w*|wtf|shit\w*|goddamn|dammit|damn it|stupid)\b", re.I)), ("told-you", re.compile(r"\bi (already |just )?told you\b|\bi asked you not\b|\bi already said\b", re.I)), @@ -83,6 +90,8 @@ """How Claude Code records a tool call a PreToolUse hook refused: an is_error tool_result whose text opens "PreToolUse:Bash hook error: []: ...".""" +RULE_PREFIX = "frustration-watchdog" + def _is_allcaps(text): letters = [c for c in text if c.isalpha()] @@ -203,32 +212,26 @@ def ends_the_wait(message): return bool(NEXT_STEP_RE.search(message) or ETA_RE.search(message)) -def main(): - try: - data = json.load(sys.stdin) - except json.JSONDecodeError: - return +def detect(data): if data.get("stop_hook_active") or data.get("agent_id"): - return + return [] message = data.get("last_assistant_message") or "" transcript_path = data.get("transcript_path") or "" if not message or not transcript_path or not os.path.isfile(transcript_path): - return + return [] try: msgs = human_user_messages(transcript_path) kinds = impatience_kinds(msgs) except Exception as exc: - print(f"catstack-hook-error frustration-watchdog: {type(exc).__name__}: {exc}", file=sys.stderr) - return # fail open: a broken watchdog must never brick a session + return [] # fail open: a broken watchdog must never brick a session if not kinds: - return + return [] if ends_the_wait(message): - return + return [] try: refused = turn_has_hook_refusal(transcript_path) unchecked = None except Exception as e: - print(f"catstack-hook-error frustration-watchdog: {type(e).__name__}: {e}", file=sys.stderr) refused = False unchecked = f"{type(e).__name__}: {e}" head = ( @@ -236,26 +239,48 @@ def main(): "and this reply hands them nothing visible. " ) if refused: - sys.stderr.write( - head + "A hook refused a tool call this turn, so you are the one blocked: " + feedback = ( + head + + "A hook refused a tool call this turn, so you are the one blocked: " "do not hand the user steps to work around it. End the wait: ask them a " "direct question, or state an explicit no-action window " - "(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules.\n" + "(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules." ) else: - sys.stderr.write( - head + "End the wait: give exactly one " + feedback = ( + head + + "End the wait: give exactly one " "concrete action for the user (\"click X\", \"run Y\", \"say Z\"), ask them a " "direct question, or state an explicit no-action window " - "(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules.\n" + "(\"nothing needed from you for ~2 min\"). Per CLAUDE.md live-demo rules." ) if unchecked: - sys.stderr.write( - f"(frustration-watchdog could not read this turn's tool results ({unchecked}), " + feedback = ( + f"catstack-hook-error frustration-watchdog: {unchecked}\n" + + feedback + + "\n" + + f"(frustration-watchdog could not read this turn's tool results ({unchecked}), " "so it could not tell whether a hook refused a tool call; the wording above " - "is the default.)\n" + "is the default.)" ) - sys.exit(2) + primary_kind = sorted(set(kinds))[0] + return [ + Finding( + rule_id=f"{RULE_PREFIX}.{primary_kind}", + subject="reply:" + hashlib.sha256(message.encode("utf-8")).hexdigest(), + message=feedback, + evidence=", ".join(sorted(set(kinds))), + ) + ] + + +def main(): + try: + run_hook("frustration-watchdog", "claude", detect, "Stop") + except SystemExit as exc: + if exc.code == 0: + return + raise if __name__ == "__main__": diff --git a/engine/hooks/frustration-watchdog/codex_stop_check.py b/engine/hooks/frustration-watchdog/codex_stop_check.py new file mode 100644 index 000000000..7d941052c --- /dev/null +++ b/engine/hooks/frustration-watchdog/codex_stop_check.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Codex Stop entrypoint for frustration-watchdog.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from claude_stop_check import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("frustration-watchdog", "codex", detect, "Stop") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/frustration-watchdog/cursor_stop_check.py b/engine/hooks/frustration-watchdog/cursor_stop_check.py new file mode 100644 index 000000000..64986bfe1 --- /dev/null +++ b/engine/hooks/frustration-watchdog/cursor_stop_check.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Cursor stop entrypoint for frustration-watchdog.""" +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_sdk")) + +from claude_stop_check import detect # noqa: E402 +from runtime import run_hook # noqa: E402 + + +def main() -> None: + run_hook("frustration-watchdog", "cursor", detect, "stop") + + +if __name__ == "__main__": + main() diff --git a/engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py b/engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py new file mode 100644 index 000000000..f26b8e619 --- /dev/null +++ b/engine/hooks/frustration-watchdog/tests/test_hooks_sdk_mode.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from test_hooks import NARRATION, WAITING, human, transcript_lines, tool_turn + +HOOK_DIR = Path(__file__).resolve().parents[1] +HOOK = HOOK_DIR / "claude_stop_check.py" + + +def payload(transcript_path: str) -> dict[str, object]: + return { + "hook_event_name": "Stop", + "session_id": "frustration-watchdog-sdk-mode", + "transcript_path": transcript_path, + "last_assistant_message": NARRATION, + "stop_hook_active": False, + } + + +def run_hook(data: dict[str, object], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + merged_env.pop("CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG", None) + merged_env.update(env) + return subprocess.run( + [sys.executable, str(HOOK)], + input=json.dumps(data), + capture_output=True, + text=True, + timeout=10, + env=merged_env, + ) + + +class SdkModeTest(unittest.TestCase): + def test_mode_override_warn_turns_stop_into_warning(self) -> None: + transcript = transcript_lines([human(WAITING)]) + try: + result = run_hook( + payload(transcript), + {"CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG": "warn"}, + ) + finally: + os.unlink(transcript) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("", result.stderr) + output = json.loads(result.stdout) + self.assertIn( + "The user's last message was impatience-shaped (waiting)", + output["hookSpecificOutput"]["additionalContext"], + ) + + def test_each_finding_writes_one_event_row_with_rule_id(self) -> None: + with tempfile.TemporaryDirectory() as metrics_dir: + transcript = transcript_lines([human(WAITING)] + tool_turn("Created PR #12", is_error=False)) + try: + result = run_hook( + payload(transcript), + { + "CATSTACK_HOOK_METRICS_DIR": metrics_dir, + "CATSTACK_HOOK_MODE_FRUSTRATION_WATCHDOG": "warn", + }, + ) + finally: + os.unlink(transcript) + rows = self._event_rows(metrics_dir) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(1, len(rows)) + self.assertEqual("frustration-watchdog", rows[0]["hook"]) + self.assertEqual("frustration-watchdog.waiting", rows[0]["rule_id"]) + self.assertEqual("warn", rows[0]["mode"]) + self.assertEqual("override", rows[0]["mode_source"]) + self.assertEqual("warned", rows[0]["action"]) + + def _event_rows(self, metrics_dir: str) -> list[dict[str, object]]: + files = list(Path(metrics_dir).glob("events-*.jsonl")) + self.assertEqual(1, len(files)) + return [json.loads(line) for line in files[0].read_text(encoding="utf-8").splitlines()] + + +if __name__ == "__main__": + unittest.main() From 24a40ecf990ff36a1ffa174f6e6535f88aaed460 Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 25 Sep 2026 03:48:05 +0000 Subject: [PATCH 2/5] =?UTF-8?q?invoker:=20wf-1789406909517-39/verify-hook-?= =?UTF-8?q?frustration-watchdog=20=E2=80=94=20Run=20the=20deterministic=20?= =?UTF-8?q?proof=20for=20put=20the=20frustration-watchdog=20hook=20onto=20?= =?UTF-8?q?the=20shared=20hook=20code.=20Review=20claim:=20The=20proof=20e?= =?UTF-8?q?xits=200=20only=20when=20put=20the=20frustration-watchdog=20hoo?= =?UTF-8?q?k=20onto=20the=20shared=20hook=20code=20holds.=20Review=20lane:?= =?UTF-8?q?=20proof=20Safety=20invariant:=20Proof=20only;=20it=20changes?= =?UTF-8?q?=20no=20product=20behavior.=20Effectiveness=20measurement:=20Th?= =?UTF-8?q?e=20exit=20status=20of=20`python3=20-m=20unittest=20discover=20?= =?UTF-8?q?-s=20engine/hooks/frustration-watchdog/tests`=20is=20the=20sign?= =?UTF-8?q?al=20for=20this=20slice.=20Slice=20rationale:=20One=20proof=20u?= =?UTF-8?q?nit=20for=20this=20workflow.=20Architectural=20effect:=20None;?= =?UTF-8?q?=20verification=20only.=20Goal:=20Prove=20put=20the=20frustrati?= =?UTF-8?q?on-watchdog=20hook=20onto=20the=20shared=20hook=20code=20with?= =?UTF-8?q?=20one=20deterministic=20run.=20Motivation:=20Each=20workflow?= =?UTF-8?q?=20carries=20its=20own=20proof=20so=20a=20reviewer=20can=20trus?= =?UTF-8?q?t=20the=20slice=20alone.=20Alternative=20considerations:=20Manu?= =?UTF-8?q?al=20inspection=20was=20set=20aside=20as=20non-deterministic.?= =?UTF-8?q?=20Implementation=20details:=20Execute=20the=20proof=20as=20a?= =?UTF-8?q?=20terminal=20gate.=20Non-goals:=20No=20product=20edits=20here.?= =?UTF-8?q?=20Layer:=20e2e=5Fregression=20Feature=20state:=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: fc63a6ee-50e8-405d-ab40-a9e97cbd27bb From 106bcc823f07c3d20314b30f7a43ea114b6f3bae Mon Sep 17 00:00:00 2001 From: Invoker Bot Date: Fri, 25 Sep 2026 03:49:11 +0000 Subject: [PATCH 3/5] =?UTF-8?q?invoker:=20wf-1789406909517-39/scrub-handof?= =?UTF-8?q?f-artifacts=20=E2=80=94=20Terminal=20read-only=20gate=20confirm?= =?UTF-8?q?ing=20no=20ephemeral=20handoff=20files=20were=20left=20behind.?= =?UTF-8?q?=20Review=20claim:=20The=20workflow=20leaves=20no=20ephemeral?= =?UTF-8?q?=20handoff=20files=20in=20the=20tree.=20Review=20lane:=20proof?= =?UTF-8?q?=20Safety=20invariant:=20Read-only;=20it=20never=20deletes=20fi?= =?UTF-8?q?les,=20alters=20the=20index,=20or=20commits=20caller=20work.=20?= =?UTF-8?q?Effectiveness=20measurement:=20A=20non-zero=20exit=20when=20eph?= =?UTF-8?q?emeral=20handoff=20files=20remain=20is=20the=20signal.=20Slice?= =?UTF-8?q?=20rationale:=20One=20unit:=20the=20hygiene=20gate.=20Architect?= =?UTF-8?q?ural=20effect:=20None.=20Goal:=20Confirm=20no=20ephemeral=20han?= =?UTF-8?q?doff=20files=20remain=20after=20every=20other=20task=20finishes?= =?UTF-8?q?.=20Motivation:=20Ephemeral=20inter-task=20files=20leak=20into?= =?UTF-8?q?=20the=20diff=20and=20read=20as=20part=20of=20the=20change.=20A?= =?UTF-8?q?lternative=20considerations:=20Manual=20inspection=20was=20set?= =?UTF-8?q?=20aside=20as=20non-deterministic.=20Implementation=20details:?= =?UTF-8?q?=20Run=20scripts/scrub-handoff-artifacts.sh=20in=20check=20mode?= =?UTF-8?q?.=20Non-goals:=20No=20deletion,=20no=20index=20changes,=20no=20?= =?UTF-8?q?commits.=20Layer:=20e2e=5Fregression=20Feature=20state:=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exit code: 0 Invoker-Finalize-Id: 516807f7-381a-482a-960c-6ed9acb3a746 From 629348e40f8c89c9a34e51c14c7d148f169b2c5d Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Fri, 25 Sep 2026 12:00:44 +0800 Subject: [PATCH 4/5] Keep frustration-watchdog's caught-error line The move onto the shared hook code dropped the catstack-hook-error print that main added in #514, leaving the exception unused (ruff F841) and the failure silent. Put the print back. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I50c3d91a397eaeb51ae31bc61ce9e1cfa5bb17d6 --- engine/hooks/frustration-watchdog/claude_stop_check.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/hooks/frustration-watchdog/claude_stop_check.py b/engine/hooks/frustration-watchdog/claude_stop_check.py index f2610d53e..74d630de4 100755 --- a/engine/hooks/frustration-watchdog/claude_stop_check.py +++ b/engine/hooks/frustration-watchdog/claude_stop_check.py @@ -223,6 +223,7 @@ def detect(data): msgs = human_user_messages(transcript_path) kinds = impatience_kinds(msgs) except Exception as exc: + print(f"catstack-hook-error frustration-watchdog: {type(exc).__name__}: {exc}", file=sys.stderr) return [] # fail open: a broken watchdog must never brick a session if not kinds: return [] From 478762bcbbc19418ade627131695d70e9424454f Mon Sep 17 00:00:00 2001 From: Edbert Chan Date: Fri, 25 Sep 2026 12:51:59 +0800 Subject: [PATCH 5/5] Log frustration-watchdog's second caught error, as main does The migration dropped main's catstack-hook-error print from the turn_has_hook_refusal handler (CI's silent-exception gate) and turned a kept comment into a new comment line (CI's no-new-comments gate). Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I0c0f7784d59fadf78cf35cf09ef775cff5047874 --- engine/hooks/frustration-watchdog/claude_stop_check.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/hooks/frustration-watchdog/claude_stop_check.py b/engine/hooks/frustration-watchdog/claude_stop_check.py index 74d630de4..8b9e6f513 100755 --- a/engine/hooks/frustration-watchdog/claude_stop_check.py +++ b/engine/hooks/frustration-watchdog/claude_stop_check.py @@ -224,7 +224,7 @@ def detect(data): kinds = impatience_kinds(msgs) except Exception as exc: print(f"catstack-hook-error frustration-watchdog: {type(exc).__name__}: {exc}", file=sys.stderr) - return [] # fail open: a broken watchdog must never brick a session + return [] if not kinds: return [] if ends_the_wait(message): @@ -233,6 +233,7 @@ def detect(data): refused = turn_has_hook_refusal(transcript_path) unchecked = None except Exception as e: + print(f"catstack-hook-error frustration-watchdog: {type(e).__name__}: {e}", file=sys.stderr) refused = False unchecked = f"{type(e).__name__}: {e}" head = (