Skip to content

Commit 3ca4351

Browse files
committed
fix: address codex review findings on PR #8
- _is_success: trust Bash exit_code when present; fall through to generic heuristic when absent so alternate response shapes are still caught - on_failure: accept importance/pain_score overrides so failed high-stakes operations record their real severity instead of the flat 7/8 defaults - _load_user_patterns: validate each regex fragment standalone, drop invalid ones with a stderr warning - _build_with_fallback: incrementally build the combined matcher; drop only fragments that are incompatible with what's been kept so far, preserving universals and good user fragments - verify_codex_fixes.py: 20 regression checks, renamed from test_*.py to sidestep pytest collection, uses a repo-local tempdir fallback
1 parent 5d5b110 commit 3ca4351

3 files changed

Lines changed: 490 additions & 18 deletions

File tree

.agent/harness/hooks/claude_code_post_tool.py

Lines changed: 156 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

101120
def _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

115165
def _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

.agent/harness/hooks/on_failure.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def _count_recent_failures(skill_name):
3232

3333

3434
def on_failure(skill_name, action, error, context="", confidence=0.9,
35-
evidence_ids=None):
35+
evidence_ids=None, importance=None, pain_score=None):
3636
# Format reflection without the noisy `type(error).__name__:` prefix
3737
# when the caller passes a pre-formatted string (the common case for
3838
# hook callers). Only include the type name for actual Exception objects
@@ -43,14 +43,17 @@ def on_failure(skill_name, action, error, context="", confidence=0.9,
4343
else:
4444
_refl = f"FAILURE in {skill_name}: {str(error)[:200]}"
4545

46+
# Let callers override the generic (7/8) defaults so a failed deploy or
47+
# schema migration is recorded with its true importance and pain score;
48+
# the dream-cycle salience can't distinguish failure severity otherwise.
4649
entry = {
4750
"timestamp": datetime.datetime.now().isoformat(),
4851
"skill": skill_name,
4952
"action": action[:200],
5053
"result": "failure",
5154
"detail": str(error)[:500],
52-
"pain_score": 8,
53-
"importance": 7,
55+
"pain_score": pain_score if pain_score is not None else 8,
56+
"importance": importance if importance is not None else 7,
5457
"reflection": _refl,
5558
"context": context[:300],
5659
"confidence": confidence,

0 commit comments

Comments
 (0)