@@ -93,23 +93,73 @@ def _load_user_patterns() -> tuple[list[str], list[str]]:
9393 cfg = json .load (f )
9494 except (OSError , json .JSONDecodeError ):
9595 return [], []
96- high = [str (p ) for p in cfg .get ("high_stakes" , []) if p ]
97- medium = [str (p ) for p in cfg .get ("medium_stakes" , []) if p ]
98- return high , medium
96+ raw_high = [str (p ) for p in cfg .get ("high_stakes" , []) if p ]
97+ raw_medium = [str (p ) for p in cfg .get ("medium_stakes" , []) if p ]
98+ # Drop fragments that aren't valid standalone regex — a single typo
99+ # (e.g. unbalanced paren) would otherwise kill every PostToolUse
100+ # invocation until the config file is hand-fixed.
101+ return _filter_valid (raw_high ), _filter_valid (raw_medium )
102+
103+
104+ def _filter_valid (fragments : list [str ]) -> list [str ]:
105+ good = []
106+ for frag in fragments :
107+ try :
108+ re .compile (frag )
109+ except re .error as e :
110+ import sys
111+ print (
112+ f"hook_patterns.json: skipping invalid regex { frag !r} : { e } " ,
113+ file = sys .stderr ,
114+ )
115+ continue
116+ good .append (frag )
117+ return good
99118
100119
101120def _build_pattern (fragments : list [str ]) -> re .Pattern | None :
121+ """Compile fragments into a combined word-boundary pattern.
122+ Returns None on failure; caller decides on fallback behavior."""
102123 if not fragments :
103124 return None
104125 combined = r'\b(' + '|' .join (fragments ) + r')\b'
105- return re .compile (combined , re .IGNORECASE )
126+ try :
127+ return re .compile (combined , re .IGNORECASE )
128+ except re .error :
129+ return None
130+
131+
132+ def _build_with_fallback (universals : list [str ],
133+ user : list [str ]) -> re .Pattern | None :
134+ """Try merging universal + user fragments. If the merged pattern fails
135+ to compile (one fragment like `(?i)foo` that is valid standalone, OR
136+ two fragments that only conflict together like duplicate named groups),
137+ rebuild INCREMENTALLY: add each user fragment only if it still compiles
138+ with everything we've kept so far. This way one bad entry doesn't
139+ disable every custom rule, and inter-fragment conflicts are resolved
140+ first-wins (deterministic)."""
141+ merged = _build_pattern (universals + user )
142+ if merged is not None or not user :
143+ return merged
144+ import sys
145+ surviving : list [str ] = []
146+ for frag in user :
147+ if _build_pattern (universals + surviving + [frag ]) is not None :
148+ surviving .append (frag )
149+ else :
150+ print (
151+ f"hook_patterns.json: fragment { frag !r} is incompatible "
152+ "with the rest of the pattern; dropping it." ,
153+ file = sys .stderr ,
154+ )
155+ return _build_pattern (universals + surviving )
106156
107157
108158# Build once at import time. User patterns are merged in here so there's
109159# no per-call file I/O.
110160_user_high , _user_medium = _load_user_patterns ()
111- _HIGH = _build_pattern (_UNIVERSAL_HIGH + _user_high )
112- _MEDIUM = _build_pattern (_UNIVERSAL_MEDIUM + _user_medium )
161+ _HIGH = _build_with_fallback (_UNIVERSAL_HIGH , _user_high )
162+ _MEDIUM = _build_with_fallback (_UNIVERSAL_MEDIUM , _user_medium )
113163
114164
115165def _importance (tool_name : str , tool_input_str : str ) -> int :
@@ -156,8 +206,71 @@ def _pain_score(importance: int, success: bool) -> int:
156206 re .IGNORECASE ,
157207)
158208
209+ # Patterns where the user has explicitly asked the shell to mask a non-zero
210+ # exit PER-COMMAND. When a command uses these, exit_code=0 is NOT reliable,
211+ # so we fall through to the generic stdout heuristic.
212+ # Examples: `deploy || true`, `migrate || :`, `run; true`.
213+ # Deliberately NOT matching `set +e`: it's often a temporary disable around
214+ # `grep Error logfile; rc=$?; set -e`-style patterns where exit_code=0 IS
215+ # still trustworthy for the actual command.
216+ _EXIT_MASKED = re .compile (
217+ r'\|\|\s*(?:true|:|exit\s+0)' # || true || : || exit 0
218+ r'|;\s*(?:true|:)\s*$' , # ; true ; : at end of command
219+ re .IGNORECASE ,
220+ )
221+
159222
160- def _is_success (tool_name : str , resp : dict ) -> bool :
223+ _QUOTED_STRING = re .compile (
224+ r"'[^']*'" # single-quoted (no escapes in bash)
225+ r'|"(?:[^"\\]|\\.)*"' , # double-quoted, honoring backslash escapes
226+ )
227+
228+
229+ def _is_exit_masked (command : str ) -> bool :
230+ """Return True if the Bash command explicitly suppresses its exit code.
231+ Strips single/double-quoted regions before matching so that masked-exit
232+ tokens inside quoted strings (e.g. `echo '... || true ...'`) don't
233+ produce false positives. Heredocs are not parsed; that corner case
234+ (text between <<EOF ... EOF lines containing || true) can still slip
235+ through, but is rare enough in real Bash tool use to accept."""
236+ if not command :
237+ return False
238+ stripped = _QUOTED_STRING .sub ("" , command )
239+ return bool (_EXIT_MASKED .search (stripped ))
240+
241+
242+ def _extract_bash_command (tool_input : dict ) -> str :
243+ """Pull the Bash command string from tool_input, supporting both the
244+ modern `{"command": "..."}` shape and the env-var fallback `{"raw": "..."}`
245+ shape that `main()` constructs from `CLAUDE_TOOL_INPUT`."""
246+ if not isinstance (tool_input , dict ):
247+ return ""
248+ cmd = tool_input .get ("command" )
249+ if isinstance (cmd , str ) and cmd :
250+ return cmd
251+ raw = tool_input .get ("raw" )
252+ if isinstance (raw , str ) and raw :
253+ return raw
254+ return ""
255+
256+
257+ def _is_success (tool_name : str , tool_input_or_resp , resp = None ) -> bool :
258+ """Signature:
259+ _is_success(tool_name, tool_input, resp) — preferred, 3-arg form
260+ _is_success(tool_name, resp) — legacy 2-arg form;
261+ wrapper detection off
262+ Detects failure from the tool_response dict. Conservative — only fails
263+ on unambiguous signals so we don't discard genuine successes."""
264+ # Support the legacy 2-arg call (tool_name, resp).
265+ if resp is None :
266+ tool_input : dict = {}
267+ resp = tool_input_or_resp
268+ else :
269+ tool_input = tool_input_or_resp if isinstance (tool_input_or_resp , dict ) else {}
270+ return _is_success_impl (tool_name , tool_input , resp )
271+
272+
273+ def _is_success_impl (tool_name : str , tool_input : dict , resp : dict ) -> bool :
161274 """Detect failure from the tool_response dict. Conservative — only fails
162275 on unambiguous signals so we don't discard genuine successes."""
163276 if not isinstance (resp , dict ):
@@ -167,18 +280,44 @@ def _is_success(tool_name: str, resp: dict) -> bool:
167280 if resp .get ("is_error" , False ):
168281 return False
169282
170- # Bash-specific: exit code and error stream
283+ # Bash-specific. Classification rules, in order:
284+ # 1. interrupted → failure
285+ # 2. stderr looks like an error (length threshold differs by wrapper):
286+ # - wrapped (`|| true`, `|| :`): any non-empty error-looking stderr
287+ # catches masked failures, since they often emit brief messages
288+ # like "build failed" or "permission denied".
289+ # - unwrapped: require >30 chars to avoid tripping on benign warnings
290+ # 3. if exit_code is present and no wrapper override fired → trust it.
291+ # Trusting exit_code=0 matters for `grep Error log`, `cat log`,
292+ # `grep X || true`, and other inspections that legitimately print
293+ # error-looking stdout.
294+ # 4. otherwise → fall through to generic stdout heuristic (handles
295+ # non-Bash tools and ancient response shapes without exit_code).
171296 if tool_name == "Bash" :
172297 exit_code = resp .get ("exit_code" )
173- if exit_code is not None and exit_code != 0 :
174- return False
175298 if resp .get ("interrupted" , False ):
176299 return False
177300 stderr = resp .get ("error" , "" ) or resp .get ("stderr" , "" ) or ""
178- if len (stderr ) > 30 and _ERROR_SIGNALS .search (stderr ):
179- return False
180-
181- # Generic output error heuristic (for non-Bash tools)
301+ command = _extract_bash_command (tool_input )
302+ wrapped = _is_exit_masked (command )
303+ if wrapped :
304+ # Masked exit. stderr is the best available signal; catch even
305+ # short messages because masked failures are often terse.
306+ if stderr and _ERROR_SIGNALS .search (stderr ):
307+ return False
308+ # No stderr signal → trust exit_code. Prevents false-failure
309+ # misclassification of `grep '^Error' log || true` (benign).
310+ if exit_code is not None :
311+ return exit_code == 0
312+ else :
313+ if len (stderr ) > 30 and _ERROR_SIGNALS .search (stderr ):
314+ return False
315+ if exit_code is not None :
316+ return exit_code == 0
317+ # No exit_code and no stderr signal — fall through to the generic
318+ # stdout heuristic below.
319+
320+ # Generic output error heuristic (non-Bash, or Bash without exit_code)
182321 output = _extract_output (resp )
183322 if output and _ERROR_SIGNALS .search (output [:200 ]):
184323 # Only fail if the very start of output looks like an error,
@@ -449,7 +588,7 @@ def main() -> None:
449588 tool_response = {"raw" : raw_resp_env }
450589
451590 # --- derive everything ---
452- success = _is_success (tool_name , tool_response )
591+ success = _is_success (tool_name , tool_input , tool_response )
453592 importance = _importance (tool_name , json .dumps (tool_input ))
454593 action = _action_label (tool_name , tool_input )
455594 reflection = _reflection (tool_name , tool_input , tool_response , success )
@@ -475,6 +614,8 @@ def main() -> None:
475614 error = reflection ,
476615 context = detail ,
477616 confidence = 0.7 ,
617+ importance = importance ,
618+ pain_score = pscore ,
478619 )
479620
480621
0 commit comments