agent-composer 0.3.1 → 0.4.1

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