agent-composer 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +495 -180
  2. package/composer.config.schema.json +206 -2
  3. package/dist/cli/cleanup.d.ts +24 -0
  4. package/dist/cli/cleanup.js +151 -0
  5. package/dist/cli/cleanup.js.map +1 -0
  6. package/dist/cli/doctor.d.ts +12 -0
  7. package/dist/cli/doctor.js +244 -4
  8. package/dist/cli/doctor.js.map +1 -1
  9. package/dist/cli/goal.d.ts +28 -0
  10. package/dist/cli/goal.js +251 -0
  11. package/dist/cli/goal.js.map +1 -0
  12. package/dist/cli/help.d.ts +3 -0
  13. package/dist/cli/help.js +31 -0
  14. package/dist/cli/help.js.map +1 -0
  15. package/dist/cli/init.d.ts +5 -0
  16. package/dist/cli/init.js +116 -21
  17. package/dist/cli/init.js.map +1 -1
  18. package/dist/cli/initArgs.d.ts +16 -0
  19. package/dist/cli/initArgs.js +19 -0
  20. package/dist/cli/initArgs.js.map +1 -0
  21. package/dist/cli/installGitHook.d.ts +7 -0
  22. package/dist/cli/installGitHook.js +61 -0
  23. package/dist/cli/installGitHook.js.map +1 -0
  24. package/dist/cli/mode.d.ts +6 -0
  25. package/dist/cli/mode.js +25 -0
  26. package/dist/cli/mode.js.map +1 -0
  27. package/dist/cli/status.d.ts +105 -0
  28. package/dist/cli/status.js +400 -0
  29. package/dist/cli/status.js.map +1 -0
  30. package/dist/config/env.d.ts +1 -1
  31. package/dist/config/modes.d.ts +10 -0
  32. package/dist/config/modes.js +26 -0
  33. package/dist/config/modes.js.map +1 -0
  34. package/dist/config/oracleRole.d.ts +10 -0
  35. package/dist/config/oracleRole.js +11 -0
  36. package/dist/config/oracleRole.js.map +1 -0
  37. package/dist/config/schema.d.ts +246 -0
  38. package/dist/config/schema.js +127 -2
  39. package/dist/config/schema.js.map +1 -1
  40. package/dist/evolve/reflection.d.ts +9 -0
  41. package/dist/evolve/reflection.js +14 -0
  42. package/dist/evolve/reflection.js.map +1 -1
  43. package/dist/evolve/runner.d.ts +2 -1
  44. package/dist/evolve/runner.js +2 -1
  45. package/dist/evolve/runner.js.map +1 -1
  46. package/dist/index.js +115 -6
  47. package/dist/index.js.map +1 -1
  48. package/dist/providers/AnthropicCompatibleProvider.d.ts +13 -1
  49. package/dist/providers/AnthropicCompatibleProvider.js +115 -9
  50. package/dist/providers/AnthropicCompatibleProvider.js.map +1 -1
  51. package/dist/providers/CLIProvider.d.ts +18 -0
  52. package/dist/providers/CLIProvider.js +265 -62
  53. package/dist/providers/CLIProvider.js.map +1 -1
  54. package/dist/providers/IProvider.d.ts +12 -0
  55. package/dist/providers/SpendGuardProvider.d.ts +32 -0
  56. package/dist/providers/SpendGuardProvider.js +98 -0
  57. package/dist/providers/SpendGuardProvider.js.map +1 -0
  58. package/dist/registry.d.ts +5 -2
  59. package/dist/registry.js +17 -2
  60. package/dist/registry.js.map +1 -1
  61. package/dist/server/activeRuns.d.ts +17 -0
  62. package/dist/server/activeRuns.js +114 -0
  63. package/dist/server/activeRuns.js.map +1 -0
  64. package/dist/server/codexLifecycleRunner.d.ts +29 -0
  65. package/dist/server/codexLifecycleRunner.js +188 -0
  66. package/dist/server/codexLifecycleRunner.js.map +1 -0
  67. package/dist/server/configMutation.d.ts +22 -0
  68. package/dist/server/configMutation.js +121 -0
  69. package/dist/server/configMutation.js.map +1 -0
  70. package/dist/server/handoffContext.d.ts +1 -0
  71. package/dist/server/handoffContext.js +12 -0
  72. package/dist/server/handoffContext.js.map +1 -0
  73. package/dist/server/progress.d.ts +24 -0
  74. package/dist/server/progress.js +109 -0
  75. package/dist/server/progress.js.map +1 -0
  76. package/dist/server/toolDescriptions.d.ts +60 -0
  77. package/dist/server/toolDescriptions.js +134 -0
  78. package/dist/server/toolDescriptions.js.map +1 -0
  79. package/dist/server.d.ts +19 -25
  80. package/dist/server.js +87 -377
  81. package/dist/server.js.map +1 -1
  82. package/dist/tools/audit.d.ts +2 -0
  83. package/dist/tools/audit.js +66 -0
  84. package/dist/tools/audit.js.map +1 -0
  85. package/dist/tools/code.d.ts +2 -0
  86. package/dist/tools/code.js +160 -0
  87. package/dist/tools/code.js.map +1 -0
  88. package/dist/tools/codexLifecycle.d.ts +2 -0
  89. package/dist/tools/codexLifecycle.js +206 -0
  90. package/dist/tools/codexLifecycle.js.map +1 -0
  91. package/dist/tools/config.d.ts +2 -0
  92. package/dist/tools/config.js +183 -0
  93. package/dist/tools/config.js.map +1 -0
  94. package/dist/tools/context.d.ts +31 -0
  95. package/dist/tools/context.js +2 -0
  96. package/dist/tools/context.js.map +1 -0
  97. package/dist/tools/goal.d.ts +2 -0
  98. package/dist/tools/goal.js +159 -0
  99. package/dist/tools/goal.js.map +1 -0
  100. package/dist/tools/handoff.d.ts +2 -0
  101. package/dist/tools/handoff.js +57 -0
  102. package/dist/tools/handoff.js.map +1 -0
  103. package/dist/tools/oracle.d.ts +2 -0
  104. package/dist/tools/oracle.js +248 -0
  105. package/dist/tools/oracle.js.map +1 -0
  106. package/dist/tools/research.d.ts +2 -0
  107. package/dist/tools/research.js +51 -0
  108. package/dist/tools/research.js.map +1 -0
  109. package/dist/tools/review.d.ts +2 -0
  110. package/dist/tools/review.js +233 -0
  111. package/dist/tools/review.js.map +1 -0
  112. package/dist/tools/route.d.ts +2 -0
  113. package/dist/tools/route.js +69 -0
  114. package/dist/tools/route.js.map +1 -0
  115. package/dist/tools/session.d.ts +2 -0
  116. package/dist/tools/session.js +37 -0
  117. package/dist/tools/session.js.map +1 -0
  118. package/dist/tools/status.d.ts +2 -0
  119. package/dist/tools/status.js +34 -0
  120. package/dist/tools/status.js.map +1 -0
  121. package/dist/tools/workflow.d.ts +2 -0
  122. package/dist/tools/workflow.js +27 -0
  123. package/dist/tools/workflow.js.map +1 -0
  124. package/dist/util/applyFileBlocks.d.ts +18 -0
  125. package/dist/util/applyFileBlocks.js +163 -0
  126. package/dist/util/applyFileBlocks.js.map +1 -0
  127. package/dist/util/asyncControl.d.ts +14 -0
  128. package/dist/util/asyncControl.js +106 -0
  129. package/dist/util/asyncControl.js.map +1 -0
  130. package/dist/util/auditLog.d.ts +56 -0
  131. package/dist/util/auditLog.js +232 -0
  132. package/dist/util/auditLog.js.map +1 -0
  133. package/dist/util/codexLifecycle.d.ts +55 -0
  134. package/dist/util/codexLifecycle.js +102 -0
  135. package/dist/util/codexLifecycle.js.map +1 -0
  136. package/dist/util/codexLifecycleJob.d.ts +209 -0
  137. package/dist/util/codexLifecycleJob.js +360 -0
  138. package/dist/util/codexLifecycleJob.js.map +1 -0
  139. package/dist/util/composerDisabled.d.ts +6 -0
  140. package/dist/util/composerDisabled.js +27 -0
  141. package/dist/util/composerDisabled.js.map +1 -0
  142. package/dist/util/dispatchHint.d.ts +5 -3
  143. package/dist/util/dispatchHint.js +62 -2
  144. package/dist/util/dispatchHint.js.map +1 -1
  145. package/dist/util/goal.d.ts +132 -0
  146. package/dist/util/goal.js +616 -0
  147. package/dist/util/goal.js.map +1 -0
  148. package/dist/util/goalReport.d.ts +51 -0
  149. package/dist/util/goalReport.js +164 -0
  150. package/dist/util/goalReport.js.map +1 -0
  151. package/dist/util/jobPolling.d.ts +9 -0
  152. package/dist/util/jobPolling.js +17 -0
  153. package/dist/util/jobPolling.js.map +1 -0
  154. package/dist/util/oracleJob.d.ts +66 -0
  155. package/dist/util/oracleJob.js +295 -0
  156. package/dist/util/oracleJob.js.map +1 -0
  157. package/dist/util/oracleLock.d.ts +38 -0
  158. package/dist/util/oracleLock.js +182 -0
  159. package/dist/util/oracleLock.js.map +1 -0
  160. package/dist/util/reviewDiff.d.ts +8 -0
  161. package/dist/util/reviewDiff.js +29 -0
  162. package/dist/util/reviewDiff.js.map +1 -0
  163. package/dist/util/reviewJob.d.ts +57 -0
  164. package/dist/util/reviewJob.js +207 -0
  165. package/dist/util/reviewJob.js.map +1 -0
  166. package/dist/util/workflowPlan.d.ts +24 -0
  167. package/dist/util/workflowPlan.js +49 -0
  168. package/dist/util/workflowPlan.js.map +1 -0
  169. package/package.json +8 -1
  170. package/plugin/composer-mastermind/commands/evolve.md +4 -0
  171. package/plugin/composer-mastermind/hooks/boundary_guard.sh +43 -2
  172. package/plugin/composer-mastermind/hooks/codex_warm_review.sh +161 -9
  173. package/plugin/composer-mastermind/hooks/learn.sh +172 -32
  174. package/plugin/composer-mastermind/hooks/precommit_codex_review.sh +430 -62
  175. package/plugin/composer-mastermind/skills/composer-mastermind/SKILL.md +184 -4
  176. package/scripts/composer-oracle-router-safe.sh +47 -0
  177. package/scripts/composer-statusline-segment.mjs +40 -0
  178. package/scripts/oracle-codex-handoff-safe.sh +49 -0
  179. package/scripts/oracle-plan-mcp.sh +66 -0
  180. package/scripts/oracle-pro-safe.sh +471 -0
@@ -8,13 +8,16 @@
8
8
  # allow the commit unless codexReview.preCommitHook.failClosed is true.
9
9
  # Config keys:
10
10
  # codexReview.preCommitCommand, scope, base, model
11
- # codexReview.preCommitHook.enabled, blockOnSeverity, timeoutMs, failClosed
11
+ # codexReview.preCommitHook.enabled, blockOnSeverity, timeoutMs, failClosed, maxConsecutiveBlocks
12
12
  # codexReview.warmCache.enabled, maxAgeMinutes
13
13
  # codexReview.notify.desktop
14
14
 
15
15
  set -u
16
16
 
17
- RUN_LOG="/tmp/composer-codex-review-log.jsonl"
17
+ RUN_LOG="${RUN_LOG:-/tmp/composer-codex-review-log.jsonl}"
18
+ PRECOMMIT_DEFAULT_TIMEOUT_MS=120000
19
+ PRECOMMIT_MIN_HARD_CAP_MS=120000
20
+ PRECOMMIT_MAX_HARD_CAP_MS=180000
18
21
 
19
22
  composer_disabled() {
20
23
  case "${COMPOSER_ENABLED:-}" in
@@ -45,6 +48,10 @@ json_escape_fallback() {
45
48
 
46
49
  emit_json() {
47
50
  local message="$1"
51
+ if [[ "${GITHOOK:-0}" == "1" ]]; then
52
+ printf '%s\n' "$message" >&2
53
+ return 0
54
+ fi
48
55
  jq -nc --arg systemMessage "$message" \
49
56
  '{systemMessage:$systemMessage,suppressOutput:true}' 2>/dev/null \
50
57
  || printf '{"systemMessage":"%s","suppressOutput":true}\n' "$(json_escape_fallback "$message")"
@@ -53,6 +60,11 @@ emit_json() {
53
60
  emit_deny() {
54
61
  local reason="$1"
55
62
  local message="${2:-$reason}"
63
+ if [[ "${GITHOOK:-0}" == "1" ]]; then
64
+ printf '%s\n' "$message" >&2
65
+ printf 'commit blocked by Codex pre-commit review: %s\n' "$reason" >&2
66
+ exit 1
67
+ fi
56
68
  jq -nc --arg r "$reason" --arg systemMessage "$message" \
57
69
  '{systemMessage:$systemMessage,suppressOutput:true,hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"deny", permissionDecisionReason:$r}}' 2>/dev/null \
58
70
  || printf '{"systemMessage":"%s","suppressOutput":true,"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$(json_escape_fallback "$message")" "$(json_escape_fallback "$reason")"
@@ -67,6 +79,8 @@ append_run_log() {
67
79
  local findings="$5"
68
80
  local scope="$6"
69
81
  local diff_hash="$7"
82
+ local reason_code="${8:-}"
83
+ local stage="${9:-precommit_codex_review}"
70
84
  jq -nc \
71
85
  --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
72
86
  --arg verdict "$verdict" \
@@ -74,9 +88,11 @@ append_run_log() {
74
88
  --arg source "$source" \
75
89
  --arg scope "$scope" \
76
90
  --arg diff_hash "$diff_hash" \
91
+ --arg reason_code "$reason_code" \
92
+ --arg stage "$stage" \
77
93
  --argjson duration_ms "${duration_ms:-0}" \
78
94
  --argjson findings "${findings:-0}" \
79
- '{ts:$ts,verdict:$verdict,decision:$decision,source:$source,duration_ms:$duration_ms,findings:$findings,scope:$scope,diff_hash:$diff_hash}' \
95
+ '{ts:$ts,verdict:$verdict,decision:$decision,source:$source,duration_ms:$duration_ms,elapsed_wall_ms:$duration_ms,findings:$findings,scope:$scope,diff_hash:$diff_hash,stage:$stage,reason_code:(if $reason_code == "" then null else $reason_code end)}' \
80
96
  >> "$RUN_LOG" 2>/dev/null || true
81
97
  }
82
98
 
@@ -84,16 +100,41 @@ fail_review() {
84
100
  local fail_closed="$1"
85
101
  local reason="$2"
86
102
  local duration_ms="${3:-0}"
103
+ local reason_code="${4:-review_unavailable}"
87
104
  if [[ "$fail_closed" == "true" ]]; then
88
- append_run_log "error" "deny" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}"
105
+ append_run_log "error" "deny" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}" "$reason_code"
89
106
  emit_deny "codex pre-commit review unavailable (fail-closed): $reason" "⛔ Codex review unavailable (fail-closed): $reason"
90
107
  fi
91
108
  printf 'codex pre-commit review skipped: %s\n' "$reason" >&2
92
- append_run_log "skip" "allow" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}"
109
+ append_run_log "skip" "allow" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}" "$reason_code"
93
110
  emit_json "⚠️ Codex pre-commit review skipped: $reason — commit allowed (fail-open)"
94
111
  exit 0
95
112
  }
96
113
 
114
+ resolve_precommit_hard_cap_ms() {
115
+ local configured="${COMPOSER_PRECOMMIT_HOOK_MAX_TIMEOUT_MS:-$PRECOMMIT_MAX_HARD_CAP_MS}"
116
+ case "$configured" in
117
+ ''|*[!0-9]*) configured="$PRECOMMIT_MAX_HARD_CAP_MS" ;;
118
+ esac
119
+ if [[ "$configured" -lt "$PRECOMMIT_MIN_HARD_CAP_MS" ]]; then
120
+ configured="$PRECOMMIT_MIN_HARD_CAP_MS"
121
+ elif [[ "$configured" -gt "$PRECOMMIT_MAX_HARD_CAP_MS" ]]; then
122
+ configured="$PRECOMMIT_MAX_HARD_CAP_MS"
123
+ fi
124
+ printf '%s\n' "$configured"
125
+ }
126
+
127
+ normalize_precommit_timeout_ms() {
128
+ local requested="$1"
129
+ local cap
130
+ cap="$(resolve_precommit_hard_cap_ms)"
131
+ if [[ "$requested" -gt "$cap" ]]; then
132
+ printf '%s\n' "$cap"
133
+ else
134
+ printf '%s\n' "$requested"
135
+ fi
136
+ }
137
+
97
138
  rank_severity() {
98
139
  case "$1" in
99
140
  critical) printf '4' ;;
@@ -128,6 +169,14 @@ hash_stdin_16() {
128
169
  fi
129
170
  }
130
171
 
172
+ git_timeout() {
173
+ if command -v timeout >/dev/null 2>&1; then
174
+ timeout 5 git "$@"
175
+ else
176
+ git "$@"
177
+ fi
178
+ }
179
+
131
180
  compute_repo_hash() {
132
181
  printf '%s' "$1" | hash_stdin_16
133
182
  }
@@ -174,12 +223,12 @@ compute_diff_hash() {
174
223
  local base_ref merge_base branch_diff
175
224
  # blockOnSeverity is excluded because threshold is re-applied at gate-read time from the cached findings array.
176
225
  {
177
- git -C "$root" diff HEAD 2>/dev/null
178
- git -C "$root" diff --cached 2>/dev/null
226
+ git_timeout -C "$root" diff HEAD 2>/dev/null
227
+ git_timeout -C "$root" diff --cached 2>/dev/null
179
228
  if [[ "$scope" == "branch" ]]; then
180
- if base_ref="$(git -C "$root" rev-parse --verify "${base}^{commit}" 2>/dev/null)" \
181
- && merge_base="$(git -C "$root" merge-base "$base" HEAD 2>/dev/null)" \
182
- && branch_diff="$(git -C "$root" diff "$base...HEAD" 2>/dev/null)"; then
229
+ if base_ref="$(git_timeout -C "$root" rev-parse --verify "${base}^{commit}" 2>/dev/null)" \
230
+ && merge_base="$(git_timeout -C "$root" merge-base "$base" HEAD 2>/dev/null)" \
231
+ && branch_diff="$(git_timeout -C "$root" diff "$base...HEAD" 2>/dev/null)"; then
183
232
  printf '\ncomposer-codex-review-branch\nbaseRef=%s\nmergeBase=%s\n' "$base_ref" "$merge_base"
184
233
  printf '%s' "$branch_diff"
185
234
  printf '\n'
@@ -191,7 +240,7 @@ compute_diff_hash() {
191
240
 
192
241
  find_git_root() {
193
242
  local start="${CLAUDE_PROJECT_DIR:-.}"
194
- git -C "$start" rev-parse --show-toplevel 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null
243
+ git_timeout -C "$start" rev-parse --show-toplevel 2>/dev/null || git_timeout rev-parse --show-toplevel 2>/dev/null
195
244
  }
196
245
 
197
246
  find_codex_plugin_root() {
@@ -226,22 +275,56 @@ find_codex_plugin_root() {
226
275
  fi
227
276
  }
228
277
 
278
+ # kill_tree: fallback teardown when no process group could be created. STOP
279
+ # each node BEFORE enumerating its children so it cannot fork a new child
280
+ # mid-teardown (the race), then CONT so a delivered TERM is actually acted
281
+ # on. Note: a descendant that has already reparented (double-fork) is not
282
+ # reachable via pgrep -P walking — the process-group path below handles that.
283
+ kill_tree() {
284
+ local sig="$1" root="$2" child
285
+ kill -STOP "$root" 2>/dev/null || true
286
+ for child in $(pgrep -P "$root" 2>/dev/null); do
287
+ kill_tree "$sig" "$child"
288
+ done
289
+ kill -"$sig" "$root" 2>/dev/null || true
290
+ kill -CONT "$root" 2>/dev/null || true
291
+ }
292
+
293
+ # teardown_reviewer: prefer an ATOMIC process-group signal (kill -SIG -PGID)
294
+ # when the reviewer leads its own group — this also reaches descendants that
295
+ # reparented away from the immediate child. Fall back to kill_tree otherwise.
296
+ teardown_reviewer() {
297
+ local sig="$1" pid="$2" pgid_mode="$3"
298
+ if [[ "$pgid_mode" == "1" ]] && kill -"$sig" "-$pid" 2>/dev/null; then
299
+ return 0
300
+ fi
301
+ kill_tree "$sig" "$pid"
302
+ }
303
+
229
304
  run_reviewer() {
230
305
  local timeout_seconds="$1"
231
306
  shift
232
- if [[ "${COMPOSER_FORCE_BASH_TIMEOUT:-}" != "1" ]] && command -v timeout >/dev/null 2>&1; then
233
- timeout "$timeout_seconds" "$@"
234
- return $?
235
- fi
236
- if [[ "${COMPOSER_FORCE_BASH_TIMEOUT:-}" != "1" ]] && command -v gtimeout >/dev/null 2>&1; then
237
- gtimeout "$timeout_seconds" "$@"
238
- return $?
239
- fi
240
307
 
241
- local pid watchdog status marker
308
+ local pid watchdog status marker pgid_mode
242
309
  marker="${TMPDIR:-/tmp}/composer-timeout.$$.$RANDOM"
243
- "$@" &
244
- pid=$!
310
+ # Launch the reviewer as the leader of a NEW process group so the watchdog
311
+ # can signal the whole subtree atomically (catching reparented grandchildren
312
+ # that hold the stdout pipe). setsid (Linux) or perl setpgrp (portable;
313
+ # ships on macOS where setsid does not) make the child its own group leader.
314
+ # If neither exists, fall back to in-group launch + kill_tree teardown.
315
+ pgid_mode=0
316
+ if command -v setsid >/dev/null 2>&1; then
317
+ setsid "$@" &
318
+ pid=$!
319
+ pgid_mode=1
320
+ elif command -v perl >/dev/null 2>&1; then
321
+ perl -e 'setpgrp(0,0); exec @ARGV or die "exec failed: $!"' "$@" &
322
+ pid=$!
323
+ pgid_mode=1
324
+ else
325
+ "$@" &
326
+ pid=$!
327
+ fi
245
328
  (
246
329
  sleeper=""
247
330
  trap '[[ -n "$sleeper" ]] && kill "$sleeper" 2>/dev/null || true; exit 0' TERM INT
@@ -249,9 +332,9 @@ run_reviewer() {
249
332
  sleeper=$!
250
333
  wait "$sleeper" 2>/dev/null || exit 0
251
334
  printf '1' >"$marker" 2>/dev/null || true
252
- kill -TERM "$pid" 2>/dev/null || true
335
+ teardown_reviewer TERM "$pid" "$pgid_mode"
253
336
  sleep 5
254
- kill -KILL "$pid" 2>/dev/null || true
337
+ teardown_reviewer KILL "$pid" "$pgid_mode"
255
338
  ) &
256
339
  watchdog=$!
257
340
  wait "$pid"
@@ -312,6 +395,68 @@ write_cache() {
312
395
  > "$tmp" 2>/dev/null && chmod 600 "$tmp" 2>/dev/null && mv "$tmp" "$cache_file" 2>/dev/null || rm -f "$tmp"
313
396
  }
314
397
 
398
+ compute_work_hash() {
399
+ local root="$1"
400
+ local base="$2"
401
+ local branch
402
+ branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || printf 'unknown')"
403
+ printf 'branch=%s\nbase=%s\n' "$branch" "$base" | hash_stdin_16
404
+ }
405
+
406
+ read_block_counter() {
407
+ local counter_file="$1"
408
+ if ! cache_file_is_trusted "$counter_file"; then
409
+ printf '0'
410
+ return 0
411
+ fi
412
+ jq -r 'if (.count | type) == "number" and (.count >= 0) then (.count | floor) else 0 end' \
413
+ "$counter_file" 2>/dev/null || printf '0'
414
+ }
415
+
416
+ write_block_counter() {
417
+ local counter_file="$1"
418
+ local count="$2"
419
+ local tmp="${counter_file}.$$"
420
+ jq -nc \
421
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
422
+ --argjson count "$count" \
423
+ '{count:$count,ts:$ts}' \
424
+ > "$tmp" 2>/dev/null && chmod 600 "$tmp" 2>/dev/null && mv "$tmp" "$counter_file" 2>/dev/null || rm -f "$tmp"
425
+ }
426
+
427
+ reset_block_counter() {
428
+ local counter_file="${BLOCK_COUNTER_FILE:-}"
429
+ [[ -n "$counter_file" ]] || return 0
430
+ rm -f "$counter_file" 2>/dev/null || true
431
+ }
432
+
433
+ maybe_allow_after_block_cap() {
434
+ local source="$1"
435
+ local duration_ms="$2"
436
+ local findings="$3"
437
+ local duration_s="$4"
438
+
439
+ [[ "${MAX_CONSECUTIVE_BLOCKS:-0}" =~ ^[0-9]+$ ]] || return 1
440
+ [[ "$MAX_CONSECUTIVE_BLOCKS" -gt 0 ]] || return 1
441
+ [[ -n "${BLOCK_COUNTER_FILE:-}" ]] || return 1
442
+
443
+ local current_count next_count
444
+ current_count="$(read_block_counter "$BLOCK_COUNTER_FILE")"
445
+ [[ "$current_count" =~ ^[0-9]+$ ]] || current_count=0
446
+
447
+ if [[ "$current_count" -ge "$MAX_CONSECUTIVE_BLOCKS" ]]; then
448
+ reset_block_counter
449
+ notify_desktop "Codex pre-commit review allowed after oscillation cap"
450
+ append_run_log "needs-attention" "allow-cap" "$source" "$duration_ms" "$findings" "$SCOPE" "${DIFF_HASH:-}"
451
+ emit_json "⚠️ Codex gate: allowed after ${current_count} consecutive blocks (oscillation cap reached) — review the diff manually / see the PR (${duration_s}s)"
452
+ exit 0
453
+ fi
454
+
455
+ next_count=$((current_count + 1))
456
+ write_block_counter "$BLOCK_COUNTER_FILE" "$next_count"
457
+ return 1
458
+ }
459
+
315
460
  review_from_cache() {
316
461
  local cache_file="$1"
317
462
  jq -c '{verdict:(.verdict // "error"), summary:(.summary // ""), findings:(if (.findings | type) == "array" then .findings else [] end), next_steps:[]}' "$cache_file" 2>/dev/null
@@ -322,22 +467,171 @@ cache_age_minutes() {
322
467
  jq -r '((now - (.ts | fromdateiso8601)) / 60 | floor)' "$cache_file" 2>/dev/null || printf '0'
323
468
  }
324
469
 
325
- review_has_parse_error() {
326
- jq -e '
327
- (.parseError // null) as $error
328
- | ($error != null and $error != false and (($error | tostring) | length) > 0)
329
- ' >/dev/null 2>&1
470
+ review_parse_error_message() {
471
+ local review_output="$1"
472
+ jq -r '
473
+ def parsed_json($value):
474
+ if ($value | type) == "string" then (($value | fromjson?) // {}) else {} end;
475
+ def first_error($items):
476
+ [
477
+ $items[]
478
+ | select(. != null and . != false and ((. | tostring) | length) > 0)
479
+ ]
480
+ | first // "";
481
+
482
+ (parsed_json(.rawOutput? // null)) as $rawOutputJson
483
+ | (parsed_json(.codex.stdout? // null)) as $codexStdoutJson
484
+ | first_error([
485
+ .parseError?,
486
+ .result.parseError?,
487
+ .review?.parseError?,
488
+ .review?.result?.parseError?,
489
+ .data?.parseError?,
490
+ .data?.result?.parseError?,
491
+ .output?.parseError?,
492
+ .output?.result?.parseError?,
493
+ .response?.parseError?,
494
+ .response?.result?.parseError?,
495
+ .payload?.parseError?,
496
+ .payload?.result?.parseError?,
497
+ $rawOutputJson.parseError?,
498
+ $rawOutputJson.result.parseError?,
499
+ $codexStdoutJson.parseError?,
500
+ $codexStdoutJson.result.parseError?
501
+ ])
502
+ ' <<<"$review_output" 2>/dev/null
330
503
  }
331
504
 
332
505
  normalize_review_output() {
333
506
  local review_output="$1"
334
507
  jq -c '
335
- def array_or_empty($value): if ($value | type) == "array" then $value else [] end;
336
- {
337
- verdict: (.result.verdict // .verdict // null),
338
- summary: (.result.summary // .summary // ""),
339
- findings: array_or_empty(.result.findings // .findings // []),
340
- next_steps: array_or_empty(.result.next_steps // .next_steps // [])
508
+ def parsed_json($value):
509
+ if ($value | type) == "string" then (($value | fromjson?) // {}) else {} end;
510
+ def first_value($items):
511
+ [
512
+ $items[]
513
+ | select(. != null and . != "")
514
+ ]
515
+ | first // null;
516
+ def first_text($items):
517
+ [
518
+ $items[]
519
+ | select((. | type) == "string" and length > 0)
520
+ ]
521
+ | first // "";
522
+ def first_array($items):
523
+ [
524
+ $items[]
525
+ | select((. | type) == "array")
526
+ ]
527
+ | first // [];
528
+
529
+ (parsed_json(.rawOutput? // null)) as $rawOutputJson
530
+ | (parsed_json(.codex.stdout? // null)) as $codexStdoutJson
531
+ | (parsed_json(.stdout? // null)) as $stdoutJson
532
+ | {
533
+ verdict: first_value([
534
+ .result.verdict?,
535
+ .verdict?,
536
+ .review?.result?.verdict?,
537
+ .review?.verdict?,
538
+ .data?.result?.verdict?,
539
+ .data?.verdict?,
540
+ .output?.result?.verdict?,
541
+ .output?.verdict?,
542
+ .response?.result?.verdict?,
543
+ .response?.verdict?,
544
+ .payload?.result?.verdict?,
545
+ .payload?.verdict?,
546
+ $rawOutputJson.result.verdict?,
547
+ $rawOutputJson.verdict?,
548
+ $rawOutputJson.review?.result?.verdict?,
549
+ $rawOutputJson.review?.verdict?,
550
+ $codexStdoutJson.result.verdict?,
551
+ $codexStdoutJson.verdict?,
552
+ $codexStdoutJson.review?.result?.verdict?,
553
+ $codexStdoutJson.review?.verdict?,
554
+ $stdoutJson.result.verdict?,
555
+ $stdoutJson.verdict?
556
+ ]),
557
+ summary: (first_text([
558
+ .result.summary?,
559
+ .summary?,
560
+ .review?.result?.summary?,
561
+ .review?.summary?,
562
+ .data?.result?.summary?,
563
+ .data?.summary?,
564
+ .output?.result?.summary?,
565
+ .output?.summary?,
566
+ .response?.result?.summary?,
567
+ .response?.summary?,
568
+ .payload?.result?.summary?,
569
+ .payload?.summary?,
570
+ $rawOutputJson.result.summary?,
571
+ $rawOutputJson.summary?,
572
+ $rawOutputJson.review?.result?.summary?,
573
+ $rawOutputJson.review?.summary?,
574
+ $codexStdoutJson.result.summary?,
575
+ $codexStdoutJson.summary?,
576
+ $codexStdoutJson.review?.result?.summary?,
577
+ $codexStdoutJson.review?.summary?,
578
+ $stdoutJson.result.summary?,
579
+ $stdoutJson.summary?
580
+ ]) // ""),
581
+ findings: first_array([
582
+ .result.findings?,
583
+ .findings?,
584
+ .review?.result?.findings?,
585
+ .review?.findings?,
586
+ .data?.result?.findings?,
587
+ .data?.findings?,
588
+ .output?.result?.findings?,
589
+ .output?.findings?,
590
+ .response?.result?.findings?,
591
+ .response?.findings?,
592
+ .payload?.result?.findings?,
593
+ .payload?.findings?,
594
+ $rawOutputJson.result.findings?,
595
+ $rawOutputJson.findings?,
596
+ $rawOutputJson.review?.result?.findings?,
597
+ $rawOutputJson.review?.findings?,
598
+ $codexStdoutJson.result.findings?,
599
+ $codexStdoutJson.findings?,
600
+ $codexStdoutJson.review?.result?.findings?,
601
+ $codexStdoutJson.review?.findings?,
602
+ $stdoutJson.result.findings?,
603
+ $stdoutJson.findings?
604
+ ]),
605
+ next_steps: first_array([
606
+ .result.next_steps?,
607
+ .next_steps?,
608
+ .review?.result?.next_steps?,
609
+ .review?.next_steps?,
610
+ .data?.result?.next_steps?,
611
+ .data?.next_steps?,
612
+ .output?.result?.next_steps?,
613
+ .output?.next_steps?,
614
+ .response?.result?.next_steps?,
615
+ .response?.next_steps?,
616
+ .payload?.result?.next_steps?,
617
+ .payload?.next_steps?,
618
+ $rawOutputJson.result.next_steps?,
619
+ $rawOutputJson.next_steps?,
620
+ $rawOutputJson.review?.result?.next_steps?,
621
+ $rawOutputJson.review?.next_steps?,
622
+ $codexStdoutJson.result.next_steps?,
623
+ $codexStdoutJson.next_steps?,
624
+ $codexStdoutJson.review?.result?.next_steps?,
625
+ $codexStdoutJson.review?.next_steps?,
626
+ $stdoutJson.result.next_steps?,
627
+ $stdoutJson.next_steps?
628
+ ]),
629
+ raw_text: first_text([
630
+ .codex.stdout?,
631
+ .rawOutput?,
632
+ .stdout?,
633
+ .review?
634
+ ])
341
635
  }
342
636
  ' <<<"$review_output" 2>/dev/null
343
637
  }
@@ -358,6 +652,7 @@ evaluate_review_output() {
358
652
  case "$verdict" in
359
653
  approve)
360
654
  printf 'codex pre-commit review: approve\n' >&2
655
+ reset_block_counter
361
656
  append_run_log "approve" "allow" "$source" "$duration_ms" 0 "$SCOPE" "${DIFF_HASH:-}"
362
657
  if [[ "$source" == "cache" ]]; then
363
658
  emit_json "✅ Codex pre-commit review: approve (cached, ${cache_age}m old)"
@@ -369,6 +664,11 @@ evaluate_review_output() {
369
664
  needs-attention)
370
665
  ;;
371
666
  *)
667
+ local raw_text_len
668
+ raw_text_len="$(jq -r '(.raw_text // "" | length)' <<<"$review_output" 2>/dev/null || printf '0')"
669
+ if [[ -z "$verdict" && "$raw_text_len" =~ ^[0-9]+$ && "$raw_text_len" -gt 0 ]]; then
670
+ fail_review "$FAIL_CLOSED" "structured review verdict missing; use codexReview.preCommitCommand=adversarial-review for mechanical gates" "$duration_ms"
671
+ fi
372
672
  fail_review "$FAIL_CLOSED" "unknown review verdict: ${verdict:-missing}" "$duration_ms"
373
673
  ;;
374
674
  esac
@@ -399,44 +699,88 @@ evaluate_review_output() {
399
699
  | join(" | ")
400
700
  ' <<<"$review_output" 2>/dev/null || true)"
401
701
  finding_summary="$(compact_text "$finding_summary")"
702
+ maybe_allow_after_block_cap "$source" "$duration_ms" "$finding_count" "$duration_s"
402
703
  notify_desktop "Codex pre-commit review blocked: ${max_severity}"
403
704
  append_run_log "needs-attention" "deny" "$source" "$duration_ms" "$finding_count" "$SCOPE" "${DIFF_HASH:-}"
404
705
  emit_deny "Codex pre-commit review: needs-attention (>= $BLOCK_ON_SEVERITY). $summary | $finding_summary" "⛔ Codex pre-commit review: blocked (${max_severity}, ${duration_s}s)"
405
706
  fi
406
707
 
407
708
  printf 'codex pre-commit review: needs-attention but all findings below %s; allowing\n' "$BLOCK_ON_SEVERITY" >&2
709
+ reset_block_counter
408
710
  append_run_log "needs-attention" "allow" "$source" "$duration_ms" "$finding_count" "$SCOPE" "${DIFF_HASH:-}"
409
711
  emit_json "🟡 Codex pre-commit review: needs-attention below ${BLOCK_ON_SEVERITY} threshold — allowing (${duration_s}s)"
410
712
  exit 0
411
713
  }
412
714
 
413
- if ! command -v jq >/dev/null 2>&1; then
414
- printf 'codex pre-commit review skipped: jq missing\n' >&2
415
- exit 0
715
+ GITHOOK=0
716
+ if [[ "${1:-}" == "--git-hook" || "${COMPOSER_PRECOMMIT_GITHOOK:-}" == "1" ]]; then
717
+ GITHOOK=1
416
718
  fi
417
719
 
418
- INPUT="$(cat || true)"
419
- if [[ -z "$INPUT" ]]; then
420
- exit 0
421
- fi
720
+ if [[ "$GITHOOK" == "1" ]]; then
721
+ # Real git pre-commit hook: git provides no PreToolUse JSON on stdin. jq is
722
+ # still needed to parse config; if it is missing, honor failClosed via Node.
723
+ if ! command -v jq >/dev/null 2>&1; then
724
+ PREFLIGHT_CONFIG_PATH="${COMPOSER_CONFIG:-${CLAUDE_PROJECT_DIR:-.}/composer.config.json}"
725
+ PREFLIGHT_FLAGS="0 0"
726
+ if [[ -f "$PREFLIGHT_CONFIG_PATH" ]] && command -v node >/dev/null 2>&1; then
727
+ PREFLIGHT_FLAGS="$(node -e 'try{const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));const r=c.codexReview||{};const h=r.preCommitHook||{};process.stdout.write((r.enabled===true&&h.enabled===true?"1":"0")+" "+(h.failClosed===true?"1":"0"))}catch(e){process.stdout.write("0 0")}' "$PREFLIGHT_CONFIG_PATH" 2>/dev/null || printf '0 0')"
728
+ fi
729
+ if [[ "${PREFLIGHT_FLAGS%% *}" == "1" && "${PREFLIGHT_FLAGS##* }" == "1" ]]; then
730
+ emit_deny "codex pre-commit review unavailable: jq not installed (fail-closed)" "⛔ Codex pre-commit gate requires jq (fail-closed). Install jq (brew install jq)."
731
+ fi
732
+ printf 'codex pre-commit review skipped: jq not installed\n' >&2
733
+ exit 0
734
+ fi
735
+ else
736
+ INPUT="$(cat || true)"
737
+ if [[ -z "$INPUT" ]]; then
738
+ exit 0
739
+ fi
422
740
 
423
- TOOL="$(jq -r '.tool_name // empty' <<<"$INPUT" 2>/dev/null || true)"
424
- if [[ -z "$TOOL" ]]; then
425
- exit 0
426
- fi
427
- if [[ "$TOOL" != "Bash" ]]; then
428
- exit 0
429
- fi
741
+ # jq is required to parse the hook payload. If it is missing we cannot run the
742
+ # gate; honor failClosed via a jq-free (Node) config read so a configured
743
+ # fail-closed gate cannot silently fail open. Only an actual Bash `git commit`
744
+ # payload is gated; everything else is allowed through unchanged.
745
+ if ! command -v jq >/dev/null 2>&1; then
746
+ if grep -Eq '"tool_name"[[:space:]]*:[[:space:]]*"Bash"' <<<"$INPUT" \
747
+ && grep -Eq 'git[^"]*commit' <<<"$INPUT"; then
748
+ PREFLIGHT_CONFIG_PATH="${COMPOSER_CONFIG:-${CLAUDE_PROJECT_DIR:-.}/composer.config.json}"
749
+ PREFLIGHT_FLAGS="0 0"
750
+ if [[ -f "$PREFLIGHT_CONFIG_PATH" ]] && command -v node >/dev/null 2>&1; then
751
+ PREFLIGHT_FLAGS="$(node -e 'try{const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));const r=c.codexReview||{};const h=r.preCommitHook||{};process.stdout.write((r.enabled===true&&h.enabled===true?"1":"0")+" "+(h.failClosed===true?"1":"0"))}catch(e){process.stdout.write("0 0")}' "$PREFLIGHT_CONFIG_PATH" 2>/dev/null || printf '0 0')"
752
+ fi
753
+ if [[ "${PREFLIGHT_FLAGS%% *}" == "1" && "${PREFLIGHT_FLAGS##* }" == "1" ]]; then
754
+ emit_deny "codex pre-commit review unavailable: jq not installed (fail-closed)" "⛔ Codex pre-commit gate requires jq (fail-closed). Install jq (brew install jq)."
755
+ fi
756
+ fi
757
+ printf 'codex pre-commit review skipped: jq not installed\n' >&2
758
+ exit 0
759
+ fi
430
760
 
431
- COMMAND_TEXT="$(jq -r '.tool_input.command // empty' <<<"$INPUT" 2>/dev/null || true)"
432
- if [[ -z "$COMMAND_TEXT" ]]; then
433
- exit 0
434
- fi
435
- if grep -Eq '(^|[^[:alnum:]])(commit-tree|commit-graph)([^[:alnum:]]|$)|--dry-run' <<<"$COMMAND_TEXT"; then
436
- exit 0
761
+ TOOL="$(jq -r '.tool_name // empty' <<<"$INPUT" 2>/dev/null || true)"
762
+ if [[ -z "$TOOL" ]]; then
763
+ exit 0
764
+ fi
765
+ if [[ "$TOOL" != "Bash" ]]; then
766
+ exit 0
767
+ fi
768
+
769
+ COMMAND_TEXT="$(jq -r '.tool_input.command // empty' <<<"$INPUT" 2>/dev/null || true)"
770
+ if [[ -z "$COMMAND_TEXT" ]]; then
771
+ exit 0
772
+ fi
773
+ if grep -Eq '(^|[^[:alnum:]])(commit-tree|commit-graph)([^[:alnum:]]|$)|--dry-run' <<<"$COMMAND_TEXT"; then
774
+ exit 0
775
+ fi
776
+ if ! grep -Eq '(^|[^[:alnum:]])git([[:space:]]|[[:space:]].*[[:space:]])commit([[:space:]]|$)' <<<"$COMMAND_TEXT"; then
777
+ exit 0
778
+ fi
437
779
  fi
438
- if ! grep -Eq '(^|[^[:alnum:]])git([[:space:]]|[[:space:]].*[[:space:]])commit([[:space:]]|$)' <<<"$COMMAND_TEXT"; then
439
- exit 0
780
+
781
+ # Library mode: allow tests to source helpers without running the gate.
782
+ if [[ "${COMPOSER_PRECOMMIT_LIB_ONLY:-0}" == "1" ]]; then
783
+ return 0 2>/dev/null || exit 0
440
784
  fi
441
785
 
442
786
  CONFIG_PATH="${COMPOSER_CONFIG:-${CLAUDE_PROJECT_DIR:-.}/composer.config.json}"
@@ -460,8 +804,9 @@ SCOPE="$(jq -r '.codexReview.scope // "working-tree"' <<<"$CONFIG_JSON" 2>/dev/n
460
804
  BASE="$(jq -r '.codexReview.base // "main"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'main')"
461
805
  CODEX_MODEL="$(jq -r '.codexReview.model // empty' <<<"$CONFIG_JSON" 2>/dev/null || true)"
462
806
  BLOCK_ON_SEVERITY="$(jq -r '.codexReview.preCommitHook.blockOnSeverity // "high"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'high')"
463
- TIMEOUT_MS="$(jq -r '.codexReview.preCommitHook.timeoutMs // 120000' <<<"$CONFIG_JSON" 2>/dev/null || printf '120000')"
807
+ TIMEOUT_MS="$(jq -r ".codexReview.preCommitHook.timeoutMs // $PRECOMMIT_DEFAULT_TIMEOUT_MS" <<<"$CONFIG_JSON" 2>/dev/null || printf '%s' "$PRECOMMIT_DEFAULT_TIMEOUT_MS")"
464
808
  FAIL_CLOSED="$(jq -r '.codexReview.preCommitHook.failClosed // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
809
+ MAX_CONSECUTIVE_BLOCKS="$(jq -r '(.codexReview.preCommitHook.maxConsecutiveBlocks // 0) | if type == "number" and . >= 0 and . == floor then . else 0 end' <<<"$CONFIG_JSON" 2>/dev/null || printf '0')"
465
810
  WARM_CACHE_ENABLED="$(jq -r '.codexReview.warmCache.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
466
811
  WARM_CACHE_MAX_AGE_MINUTES="$(jq -r '.codexReview.warmCache.maxAgeMinutes // 30' <<<"$CONFIG_JSON" 2>/dev/null || printf '30')"
467
812
  NOTIFY_DESKTOP="$(jq -r '.codexReview.notify.desktop // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
@@ -477,6 +822,13 @@ esac
477
822
  case "$TIMEOUT_MS" in
478
823
  ''|*[!0-9]*) fail_review "$FAIL_CLOSED" "invalid timeoutMs: $TIMEOUT_MS" ;;
479
824
  esac
825
+ TIMEOUT_MS="$(normalize_precommit_timeout_ms "$TIMEOUT_MS")"
826
+ case "$MAX_CONSECUTIVE_BLOCKS" in
827
+ ''|*[!0-9]*) MAX_CONSECUTIVE_BLOCKS=0 ;;
828
+ esac
829
+ if [[ "$(printf '%s' "$CODEX_MODEL" | tr '[:upper:]' '[:lower:]')" == "gpt-5.5-pro" ]]; then
830
+ fail_review "$FAIL_CLOSED" "gpt-5.5-pro is the ChatGPT-Pro/Oracle browser lane, not a Codex CLI model"
831
+ fi
480
832
  case "$WARM_CACHE_MAX_AGE_MINUTES" in
481
833
  ''|*[!0-9]*) WARM_CACHE_MAX_AGE_MINUTES=30 ;;
482
834
  esac
@@ -488,10 +840,19 @@ fi
488
840
 
489
841
  DIFF_HASH=""
490
842
  CACHE_FILE=""
843
+ BLOCK_COUNTER_FILE=""
844
+ GIT_ROOT="$(find_git_root || true)"
845
+ if [[ -n "$GIT_ROOT" && "$MAX_CONSECUTIVE_BLOCKS" -gt 0 ]]; then
846
+ REPO_HASH="$(compute_repo_hash "$GIT_ROOT" 2>/dev/null || true)"
847
+ WORK_HASH="$(compute_work_hash "$GIT_ROOT" "$BASE" 2>/dev/null || true)"
848
+ STATE_DIR="$(ensure_state_dir 2>/dev/null || true)"
849
+ if [[ -n "$REPO_HASH" && -n "$WORK_HASH" && -n "$STATE_DIR" ]]; then
850
+ BLOCK_COUNTER_FILE="$STATE_DIR/codex-review-blocks-${REPO_HASH}-${WORK_HASH}.json"
851
+ fi
852
+ fi
491
853
  if [[ -n "${COMPOSER_CODEX_REVIEW_CMD:-}" ]]; then
492
854
  : # Test seam commands are not equivalent reviewer policy, so they never read or write warm-cache verdicts.
493
855
  elif [[ "$WARM_CACHE_ENABLED" == "true" ]]; then
494
- GIT_ROOT="$(find_git_root || true)"
495
856
  if [[ -n "$GIT_ROOT" ]]; then
496
857
  REPO_HASH="$(compute_repo_hash "$GIT_ROOT" 2>/dev/null || true)"
497
858
  DIFF_HASH="$(compute_diff_hash "$GIT_ROOT" "$REVIEW_COMMAND" "$SCOPE" "$BASE" "$CODEX_MODEL" 2>/dev/null || true)"
@@ -535,9 +896,15 @@ END_SECONDS="$(date +%s)"
535
896
  DURATION_MS=$(( (END_SECONDS - START_SECONDS) * 1000 ))
536
897
 
537
898
  if [[ "$REVIEW_STATUS" -eq 124 ]]; then
538
- fail_review "$FAIL_CLOSED" "review timed out after ${TIMEOUT_SECONDS}s" "$DURATION_MS"
899
+ fail_review "true" "review timed out after ${TIMEOUT_SECONDS}s" "$DURATION_MS" "hook_timeout"
539
900
  fi
540
901
  if [[ "$REVIEW_STATUS" -ne 0 ]]; then
902
+ if [[ -n "$REVIEW_OUTPUT" ]] && jq -e . >/dev/null 2>&1 <<<"$REVIEW_OUTPUT"; then
903
+ PARSE_ERROR_MESSAGE="$(review_parse_error_message "$REVIEW_OUTPUT")"
904
+ if [[ -n "$PARSE_ERROR_MESSAGE" ]]; then
905
+ fail_review "$FAIL_CLOSED" "review parseError: $PARSE_ERROR_MESSAGE" "$DURATION_MS"
906
+ fi
907
+ fi
541
908
  fail_review "$FAIL_CLOSED" "review command exited $REVIEW_STATUS" "$DURATION_MS"
542
909
  fi
543
910
  if [[ -z "$REVIEW_OUTPUT" ]]; then
@@ -546,8 +913,9 @@ fi
546
913
  if ! jq -e . >/dev/null 2>&1 <<<"$REVIEW_OUTPUT"; then
547
914
  fail_review "$FAIL_CLOSED" "review returned unparseable JSON" "$DURATION_MS"
548
915
  fi
549
- if review_has_parse_error <<<"$REVIEW_OUTPUT"; then
550
- fail_review "$FAIL_CLOSED" "review parseError: $(jq -r '.parseError | tostring' <<<"$REVIEW_OUTPUT" 2>/dev/null || printf 'unknown')" "$DURATION_MS"
916
+ PARSE_ERROR_MESSAGE="$(review_parse_error_message "$REVIEW_OUTPUT")"
917
+ if [[ -n "$PARSE_ERROR_MESSAGE" ]]; then
918
+ fail_review "$FAIL_CLOSED" "review parseError: $PARSE_ERROR_MESSAGE" "$DURATION_MS"
551
919
  fi
552
920
  NORMALIZED_REVIEW_OUTPUT="$(normalize_review_output "$REVIEW_OUTPUT" || true)"
553
921
  if [[ -z "$NORMALIZED_REVIEW_OUTPUT" ]] || ! jq -e . >/dev/null 2>&1 <<<"$NORMALIZED_REVIEW_OUTPUT"; then