agent-composer 0.2.2 → 0.3.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.
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env bash
2
+ # Optional mechanical Codex pre-commit review gate (PreToolUse hook).
3
+ # Blocks Bash `git commit` only when codexReview.enabled and
4
+ # codexReview.preCommitHook.enabled are true, and the Codex review verdict
5
+ # reaches the configured blockOnSeverity threshold.
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.
9
+ # Config keys:
10
+ # codexReview.preCommitCommand, scope, base, model
11
+ # codexReview.preCommitHook.enabled, blockOnSeverity, timeoutMs, failClosed
12
+ # codexReview.warmCache.enabled, maxAgeMinutes
13
+ # codexReview.notify.desktop
14
+
15
+ set -u
16
+
17
+ RUN_LOG="/tmp/composer-codex-review-log.jsonl"
18
+
19
+ composer_disabled() {
20
+ case "${COMPOSER_ENABLED:-}" in
21
+ 0|false|FALSE|off|OFF|no|NO) return 0 ;;
22
+ esac
23
+ case "${COMPOSER_DISABLED:-}" in
24
+ 1|true|TRUE|on|ON|yes|YES) return 0 ;;
25
+ esac
26
+ if [[ -n "${COMPOSER_DISABLED_FILE:-}" && -e "$COMPOSER_DISABLED_FILE" ]]; then
27
+ return 0
28
+ fi
29
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" && -e "$CLAUDE_PROJECT_DIR/.composer-disabled" ]]; then
30
+ return 0
31
+ fi
32
+ if [[ -n "${HOME:-}" && -e "$HOME/.claude/composer.disabled" ]]; then
33
+ return 0
34
+ fi
35
+ return 1
36
+ }
37
+
38
+ if composer_disabled; then
39
+ exit 0
40
+ fi
41
+
42
+ json_escape_fallback() {
43
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
44
+ }
45
+
46
+ emit_json() {
47
+ local message="$1"
48
+ jq -nc --arg systemMessage "$message" \
49
+ '{systemMessage:$systemMessage,suppressOutput:true}' 2>/dev/null \
50
+ || printf '{"systemMessage":"%s","suppressOutput":true}\n' "$(json_escape_fallback "$message")"
51
+ }
52
+
53
+ emit_deny() {
54
+ local reason="$1"
55
+ local message="${2:-$reason}"
56
+ jq -nc --arg r "$reason" --arg systemMessage "$message" \
57
+ '{systemMessage:$systemMessage,suppressOutput:true,hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"deny", permissionDecisionReason:$r}}' 2>/dev/null \
58
+ || printf '{"systemMessage":"%s","suppressOutput":true,"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$(json_escape_fallback "$message")" "$(json_escape_fallback "$reason")"
59
+ exit 0
60
+ }
61
+
62
+ append_run_log() {
63
+ local verdict="$1"
64
+ local decision="$2"
65
+ local source="$3"
66
+ local duration_ms="$4"
67
+ local findings="$5"
68
+ local scope="$6"
69
+ local diff_hash="$7"
70
+ jq -nc \
71
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
72
+ --arg verdict "$verdict" \
73
+ --arg decision "$decision" \
74
+ --arg source "$source" \
75
+ --arg scope "$scope" \
76
+ --arg diff_hash "$diff_hash" \
77
+ --argjson duration_ms "${duration_ms:-0}" \
78
+ --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}' \
80
+ >> "$RUN_LOG" 2>/dev/null || true
81
+ }
82
+
83
+ fail_review() {
84
+ local fail_closed="$1"
85
+ local reason="$2"
86
+ local duration_ms="${3:-0}"
87
+ if [[ "$fail_closed" == "true" ]]; then
88
+ append_run_log "error" "deny" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}"
89
+ emit_deny "codex pre-commit review unavailable (fail-closed): $reason" "⛔ Codex review unavailable (fail-closed): $reason"
90
+ fi
91
+ printf 'codex pre-commit review skipped: %s\n' "$reason" >&2
92
+ append_run_log "skip" "allow" "sync" "$duration_ms" 0 "${SCOPE:-}" "${DIFF_HASH:-}"
93
+ emit_json "⚠️ Codex pre-commit review skipped: $reason — commit allowed (fail-open)"
94
+ exit 0
95
+ }
96
+
97
+ rank_severity() {
98
+ case "$1" in
99
+ critical) printf '4' ;;
100
+ high) printf '3' ;;
101
+ medium) printf '2' ;;
102
+ low) printf '1' ;;
103
+ *) printf '0' ;;
104
+ esac
105
+ }
106
+
107
+ severity_for_rank() {
108
+ case "$1" in
109
+ 4) printf 'critical' ;;
110
+ 3) printf 'high' ;;
111
+ 2) printf 'medium' ;;
112
+ 1) printf 'low' ;;
113
+ *) printf 'unknown' ;;
114
+ esac
115
+ }
116
+
117
+ compact_text() {
118
+ printf '%s' "$1" | tr '\n\r\t' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
119
+ }
120
+
121
+ hash_stdin_16() {
122
+ if command -v shasum >/dev/null 2>&1; then
123
+ shasum -a 256 | awk '{print substr($1,1,16)}'
124
+ elif command -v sha256sum >/dev/null 2>&1; then
125
+ sha256sum | awk '{print substr($1,1,16)}'
126
+ else
127
+ return 1
128
+ fi
129
+ }
130
+
131
+ compute_repo_hash() {
132
+ printf '%s' "$1" | hash_stdin_16
133
+ }
134
+
135
+ ensure_state_dir() {
136
+ local dir="${COMPOSER_STATE_DIR:-${HOME:-}/.cache/composer}"
137
+ [[ -n "$dir" ]] || return 1
138
+ mkdir -p "$dir" 2>/dev/null || return 1
139
+ chmod 700 "$dir" 2>/dev/null || return 1
140
+ [[ -d "$dir" ]] || return 1
141
+ printf '%s\n' "$dir"
142
+ }
143
+
144
+ cache_file_is_trusted() {
145
+ local cache_file="$1"
146
+ [[ -f "$cache_file" && ! -L "$cache_file" ]] || return 1
147
+
148
+ local owner mode current_uid group_digit other_digit stat_out
149
+ current_uid="$(id -u 2>/dev/null)" || return 1
150
+ if stat_out="$(stat -f '%u %Lp' "$cache_file" 2>/dev/null)"; then
151
+ owner="${stat_out%% *}"
152
+ mode="${stat_out##* }"
153
+ elif stat_out="$(stat -c '%u %a' "$cache_file" 2>/dev/null)"; then
154
+ owner="${stat_out%% *}"
155
+ mode="${stat_out##* }"
156
+ else
157
+ return 1
158
+ fi
159
+
160
+ [[ "$owner" == "$current_uid" ]] || return 1
161
+ [[ "$mode" =~ ^[0-7]+$ ]] || return 1
162
+ group_digit="${mode: -2:1}"
163
+ other_digit="${mode: -1}"
164
+ (( (10#$group_digit & 2) == 0 && (10#$other_digit & 2) == 0 )) || return 1
165
+ return 0
166
+ }
167
+
168
+ compute_diff_hash() {
169
+ local root="$1"
170
+ local pre_commit_command="$2"
171
+ local scope="$3"
172
+ local base="$4"
173
+ local model="$5"
174
+ local base_ref merge_base branch_diff
175
+ # blockOnSeverity is excluded because threshold is re-applied at gate-read time from the cached findings array.
176
+ {
177
+ git -C "$root" diff HEAD 2>/dev/null
178
+ git -C "$root" diff --cached 2>/dev/null
179
+ 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
183
+ printf '\ncomposer-codex-review-branch\nbaseRef=%s\nmergeBase=%s\n' "$base_ref" "$merge_base"
184
+ printf '%s' "$branch_diff"
185
+ printf '\n'
186
+ fi
187
+ fi
188
+ printf '\ncomposer-codex-review-policy\npreCommitCommand=%s\nscope=%s\nbase=%s\nmodel=%s\n' "$pre_commit_command" "$scope" "$base" "$model"
189
+ } | hash_stdin_16
190
+ }
191
+
192
+ find_git_root() {
193
+ local start="${CLAUDE_PROJECT_DIR:-.}"
194
+ git -C "$start" rev-parse --show-toplevel 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null
195
+ }
196
+
197
+ find_codex_plugin_root() {
198
+ if [[ -n "${COMPOSER_CODEX_PLUGIN_ROOT:-}" && -f "$COMPOSER_CODEX_PLUGIN_ROOT/scripts/codex-companion.mjs" ]]; then
199
+ printf '%s\n' "$COMPOSER_CODEX_PLUGIN_ROOT"
200
+ return 0
201
+ fi
202
+
203
+ local marketplace_root="${HOME:-}/.claude/plugins/marketplaces/openai-codex/plugins/codex"
204
+ if [[ -f "$marketplace_root/scripts/codex-companion.mjs" ]]; then
205
+ printf '%s\n' "$marketplace_root"
206
+ return 0
207
+ fi
208
+
209
+ local cache_base="${HOME:-}/.claude/plugins/cache/openai-codex/codex"
210
+ [[ -d "$cache_base" ]] || return 1
211
+
212
+ local versions
213
+ if versions="$(find "$cache_base" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null)"; then
214
+ if printf '%s\n' "$versions" | sort -V >/dev/null 2>&1; then
215
+ versions="$(printf '%s\n' "$versions" | sort -V)"
216
+ else
217
+ versions="$(printf '%s\n' "$versions" | sort)"
218
+ fi
219
+ local version
220
+ while IFS= read -r version; do
221
+ [[ -n "$version" ]] || continue
222
+ if [[ -f "$cache_base/$version/scripts/codex-companion.mjs" ]]; then
223
+ printf '%s\n' "$cache_base/$version"
224
+ fi
225
+ done <<<"$versions" | tail -n 1
226
+ fi
227
+ }
228
+
229
+ run_reviewer() {
230
+ local timeout_seconds="$1"
231
+ 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
+
241
+ local pid watchdog status marker
242
+ marker="${TMPDIR:-/tmp}/composer-timeout.$$.$RANDOM"
243
+ "$@" &
244
+ pid=$!
245
+ (
246
+ sleeper=""
247
+ trap '[[ -n "$sleeper" ]] && kill "$sleeper" 2>/dev/null || true; exit 0' TERM INT
248
+ sleep "$timeout_seconds" &
249
+ sleeper=$!
250
+ wait "$sleeper" 2>/dev/null || exit 0
251
+ printf '1' >"$marker" 2>/dev/null || true
252
+ kill -TERM "$pid" 2>/dev/null || true
253
+ sleep 5
254
+ kill -KILL "$pid" 2>/dev/null || true
255
+ ) &
256
+ watchdog=$!
257
+ wait "$pid"
258
+ status=$?
259
+ kill "$watchdog" 2>/dev/null || true
260
+ wait "$watchdog" 2>/dev/null || true
261
+ if [[ -f "$marker" ]]; then
262
+ rm -f "$marker" 2>/dev/null || true
263
+ return 124
264
+ fi
265
+ rm -f "$marker" 2>/dev/null || true
266
+ return "$status"
267
+ }
268
+
269
+ run_reviewer_shell() {
270
+ local timeout_seconds="$1"
271
+ local command="$2"
272
+ run_reviewer "$timeout_seconds" bash -c "$command"
273
+ }
274
+
275
+ notify_desktop() {
276
+ local message="$1"
277
+ [[ "${NOTIFY_DESKTOP:-false}" == "true" ]] || return 0
278
+ command -v osascript >/dev/null 2>&1 || return 0
279
+ osascript -e "display notification \"$(json_escape_fallback "$message")\" with title \"Composer\"" >/dev/null 2>&1 &
280
+ }
281
+
282
+ cache_is_fresh_match() {
283
+ local cache_file="$1"
284
+ local diff_hash="$2"
285
+ local max_age_minutes="$3"
286
+ [[ -f "$cache_file" ]] || return 1
287
+ jq -e --arg hash "$diff_hash" --argjson maxAge "$max_age_minutes" '
288
+ (.hash == $hash)
289
+ and ((.ts | type) == "string")
290
+ and ((now - (.ts | fromdateiso8601)) <= ($maxAge * 60))
291
+ ' "$cache_file" >/dev/null 2>&1
292
+ }
293
+
294
+ write_cache() {
295
+ local cache_file="$1"
296
+ local diff_hash="$2"
297
+ local review_output="$3"
298
+ local duration_ms="$4"
299
+ local verdict
300
+ verdict="$(jq -r '.verdict // empty' <<<"$review_output" 2>/dev/null || true)"
301
+ case "$verdict" in
302
+ approve|needs-attention) ;;
303
+ *) return 0 ;;
304
+ esac
305
+ local tmp="${cache_file}.$$"
306
+ jq -nc \
307
+ --arg hash "$diff_hash" \
308
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
309
+ --argjson review "$review_output" \
310
+ --argjson durationMs "$duration_ms" \
311
+ '{hash:$hash, verdict:($review.verdict // "error"), summary:($review.summary // ""), findings:(if ($review.findings | type) == "array" then $review.findings else [] end), ts:$ts, durationMs:$durationMs}' \
312
+ > "$tmp" 2>/dev/null && chmod 600 "$tmp" 2>/dev/null && mv "$tmp" "$cache_file" 2>/dev/null || rm -f "$tmp"
313
+ }
314
+
315
+ review_from_cache() {
316
+ local cache_file="$1"
317
+ jq -c '{verdict:(.verdict // "error"), summary:(.summary // ""), findings:(if (.findings | type) == "array" then .findings else [] end), next_steps:[]}' "$cache_file" 2>/dev/null
318
+ }
319
+
320
+ cache_age_minutes() {
321
+ local cache_file="$1"
322
+ jq -r '((now - (.ts | fromdateiso8601)) / 60 | floor)' "$cache_file" 2>/dev/null || printf '0'
323
+ }
324
+
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
330
+ }
331
+
332
+ normalize_review_output() {
333
+ local review_output="$1"
334
+ 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 // [])
341
+ }
342
+ ' <<<"$review_output" 2>/dev/null
343
+ }
344
+
345
+ evaluate_review_output() {
346
+ local review_output="$1"
347
+ local source="$2"
348
+ local duration_ms="$3"
349
+ local cache_age="${4:-0}"
350
+ local duration_s=$(( (duration_ms + 999) / 1000 ))
351
+
352
+ if ! jq -e . >/dev/null 2>&1 <<<"$review_output"; then
353
+ fail_review "$FAIL_CLOSED" "review returned unparseable JSON" "$duration_ms"
354
+ fi
355
+
356
+ local verdict
357
+ verdict="$(jq -r '.verdict // empty' <<<"$review_output" 2>/dev/null || true)"
358
+ case "$verdict" in
359
+ approve)
360
+ printf 'codex pre-commit review: approve\n' >&2
361
+ append_run_log "approve" "allow" "$source" "$duration_ms" 0 "$SCOPE" "${DIFF_HASH:-}"
362
+ if [[ "$source" == "cache" ]]; then
363
+ emit_json "✅ Codex pre-commit review: approve (cached, ${cache_age}m old)"
364
+ else
365
+ emit_json "✅ Codex pre-commit review: approve (${duration_s}s)"
366
+ fi
367
+ exit 0
368
+ ;;
369
+ needs-attention)
370
+ ;;
371
+ *)
372
+ fail_review "$FAIL_CLOSED" "unknown review verdict: ${verdict:-missing}" "$duration_ms"
373
+ ;;
374
+ esac
375
+
376
+ local summary finding_count max_rank threshold_rank max_severity
377
+ summary="$(jq -r '.summary // empty' <<<"$review_output" 2>/dev/null || true)"
378
+ summary="$(compact_text "$summary")"
379
+ summary="${summary:0:200}"
380
+ finding_count="$(jq -r 'if (.findings | type) == "array" then (.findings | length) else 0 end' <<<"$review_output" 2>/dev/null || printf '0')"
381
+
382
+ max_rank="$(jq -r '
383
+ def rank: if . == "critical" then 4 elif . == "high" then 3 elif . == "medium" then 2 elif . == "low" then 1 else 0 end;
384
+ [.findings[]?.severity | rank] | max // 0
385
+ ' <<<"$review_output" 2>/dev/null || printf '0')"
386
+ threshold_rank="$(rank_severity "$BLOCK_ON_SEVERITY")"
387
+ max_severity="$(severity_for_rank "$max_rank")"
388
+
389
+ if [[ "$finding_count" -eq 0 || "$max_rank" -ge "$threshold_rank" ]]; then
390
+ local finding_summary
391
+ finding_summary="$(jq -r '
392
+ .findings[:3]
393
+ | map(
394
+ "[" + (.severity // "unknown") + "] "
395
+ + (.file // "<unknown>") + ":"
396
+ + ((.line_start // 0) | tostring) + " "
397
+ + (.title // "<untitled>")
398
+ )
399
+ | join(" | ")
400
+ ' <<<"$review_output" 2>/dev/null || true)"
401
+ finding_summary="$(compact_text "$finding_summary")"
402
+ notify_desktop "Codex pre-commit review blocked: ${max_severity}"
403
+ append_run_log "needs-attention" "deny" "$source" "$duration_ms" "$finding_count" "$SCOPE" "${DIFF_HASH:-}"
404
+ 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
+ fi
406
+
407
+ printf 'codex pre-commit review: needs-attention but all findings below %s; allowing\n' "$BLOCK_ON_SEVERITY" >&2
408
+ append_run_log "needs-attention" "allow" "$source" "$duration_ms" "$finding_count" "$SCOPE" "${DIFF_HASH:-}"
409
+ emit_json "🟡 Codex pre-commit review: needs-attention below ${BLOCK_ON_SEVERITY} threshold — allowing (${duration_s}s)"
410
+ exit 0
411
+ }
412
+
413
+ if ! command -v jq >/dev/null 2>&1; then
414
+ printf 'codex pre-commit review skipped: jq missing\n' >&2
415
+ exit 0
416
+ fi
417
+
418
+ INPUT="$(cat || true)"
419
+ if [[ -z "$INPUT" ]]; then
420
+ exit 0
421
+ fi
422
+
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
430
+
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
437
+ fi
438
+ if ! grep -Eq '(^|[^[:alnum:]])git([[:space:]]|[[:space:]].*[[:space:]])commit([[:space:]]|$)' <<<"$COMMAND_TEXT"; then
439
+ exit 0
440
+ fi
441
+
442
+ CONFIG_PATH="${COMPOSER_CONFIG:-${CLAUDE_PROJECT_DIR:-.}/composer.config.json}"
443
+ if [[ ! -f "$CONFIG_PATH" ]]; then
444
+ exit 0
445
+ fi
446
+
447
+ CONFIG_JSON="$(cat "$CONFIG_PATH" 2>/dev/null || true)"
448
+ if [[ -z "$CONFIG_JSON" ]] || ! jq -e . >/dev/null 2>&1 <<<"$CONFIG_JSON"; then
449
+ exit 0
450
+ fi
451
+
452
+ ENABLED="$(jq -r '.codexReview.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
453
+ HOOK_ENABLED="$(jq -r '.codexReview.preCommitHook.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
454
+ if [[ "$ENABLED" != "true" || "$HOOK_ENABLED" != "true" ]]; then
455
+ exit 0
456
+ fi
457
+
458
+ REVIEW_COMMAND="$(jq -r '.codexReview.preCommitCommand // "review"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'review')"
459
+ SCOPE="$(jq -r '.codexReview.scope // "working-tree"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'working-tree')"
460
+ BASE="$(jq -r '.codexReview.base // "main"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'main')"
461
+ CODEX_MODEL="$(jq -r '.codexReview.model // empty' <<<"$CONFIG_JSON" 2>/dev/null || true)"
462
+ 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')"
464
+ FAIL_CLOSED="$(jq -r '.codexReview.preCommitHook.failClosed // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
465
+ WARM_CACHE_ENABLED="$(jq -r '.codexReview.warmCache.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
466
+ WARM_CACHE_MAX_AGE_MINUTES="$(jq -r '.codexReview.warmCache.maxAgeMinutes // 30' <<<"$CONFIG_JSON" 2>/dev/null || printf '30')"
467
+ NOTIFY_DESKTOP="$(jq -r '.codexReview.notify.desktop // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
468
+
469
+ case "$REVIEW_COMMAND" in
470
+ review|adversarial-review) ;;
471
+ *) fail_review "$FAIL_CLOSED" "invalid preCommitCommand: $REVIEW_COMMAND" ;;
472
+ esac
473
+ case "$BLOCK_ON_SEVERITY" in
474
+ critical|high|medium|low) ;;
475
+ *) fail_review "$FAIL_CLOSED" "invalid blockOnSeverity: $BLOCK_ON_SEVERITY" ;;
476
+ esac
477
+ case "$TIMEOUT_MS" in
478
+ ''|*[!0-9]*) fail_review "$FAIL_CLOSED" "invalid timeoutMs: $TIMEOUT_MS" ;;
479
+ esac
480
+ case "$WARM_CACHE_MAX_AGE_MINUTES" in
481
+ ''|*[!0-9]*) WARM_CACHE_MAX_AGE_MINUTES=30 ;;
482
+ esac
483
+
484
+ TIMEOUT_SECONDS=$(( (TIMEOUT_MS + 999) / 1000 ))
485
+ if [[ "$TIMEOUT_SECONDS" -lt 1 ]]; then
486
+ TIMEOUT_SECONDS=1
487
+ fi
488
+
489
+ DIFF_HASH=""
490
+ CACHE_FILE=""
491
+ if [[ -n "${COMPOSER_CODEX_REVIEW_CMD:-}" ]]; then
492
+ : # Test seam commands are not equivalent reviewer policy, so they never read or write warm-cache verdicts.
493
+ elif [[ "$WARM_CACHE_ENABLED" == "true" ]]; then
494
+ GIT_ROOT="$(find_git_root || true)"
495
+ if [[ -n "$GIT_ROOT" ]]; then
496
+ REPO_HASH="$(compute_repo_hash "$GIT_ROOT" 2>/dev/null || true)"
497
+ DIFF_HASH="$(compute_diff_hash "$GIT_ROOT" "$REVIEW_COMMAND" "$SCOPE" "$BASE" "$CODEX_MODEL" 2>/dev/null || true)"
498
+ STATE_DIR="$(ensure_state_dir 2>/dev/null || true)"
499
+ if [[ -n "$REPO_HASH" && -n "$DIFF_HASH" && -n "$STATE_DIR" ]]; then
500
+ CACHE_FILE="$STATE_DIR/codex-review-cache-${REPO_HASH}.json"
501
+ if cache_file_is_trusted "$CACHE_FILE" && cache_is_fresh_match "$CACHE_FILE" "$DIFF_HASH" "$WARM_CACHE_MAX_AGE_MINUTES"; then
502
+ CACHE_OUTPUT="$(review_from_cache "$CACHE_FILE" || true)"
503
+ CACHE_AGE="$(cache_age_minutes "$CACHE_FILE")"
504
+ if [[ -n "$CACHE_OUTPUT" ]]; then
505
+ evaluate_review_output "$CACHE_OUTPUT" "cache" 0 "$CACHE_AGE"
506
+ fi
507
+ fi
508
+ fi
509
+ fi
510
+ fi
511
+
512
+ REVIEW_OUTPUT=""
513
+ REVIEW_STATUS=0
514
+ START_SECONDS="$(date +%s)"
515
+ notify_desktop "Codex pre-commit review running…"
516
+ if [[ -n "${COMPOSER_CODEX_REVIEW_CMD:-}" ]]; then
517
+ REVIEW_OUTPUT="$(run_reviewer_shell "$TIMEOUT_SECONDS" "$COMPOSER_CODEX_REVIEW_CMD" 2>/dev/null)"
518
+ REVIEW_STATUS=$?
519
+ else
520
+ CODEX_ROOT="$(find_codex_plugin_root || true)"
521
+ if [[ -z "$CODEX_ROOT" ]]; then
522
+ fail_review "$FAIL_CLOSED" "codex companion not found"
523
+ fi
524
+ REVIEW_ARGS=("node" "$CODEX_ROOT/scripts/codex-companion.mjs" "$REVIEW_COMMAND" "--wait" "--json" "--scope" "$SCOPE")
525
+ if [[ "$SCOPE" == "branch" ]]; then
526
+ REVIEW_ARGS+=("--base" "$BASE")
527
+ fi
528
+ if [[ -n "$CODEX_MODEL" ]]; then
529
+ REVIEW_ARGS+=("--model" "$CODEX_MODEL")
530
+ fi
531
+ REVIEW_OUTPUT="$(run_reviewer "$TIMEOUT_SECONDS" "${REVIEW_ARGS[@]}" 2>/dev/null)"
532
+ REVIEW_STATUS=$?
533
+ fi
534
+ END_SECONDS="$(date +%s)"
535
+ DURATION_MS=$(( (END_SECONDS - START_SECONDS) * 1000 ))
536
+
537
+ if [[ "$REVIEW_STATUS" -eq 124 ]]; then
538
+ fail_review "$FAIL_CLOSED" "review timed out after ${TIMEOUT_SECONDS}s" "$DURATION_MS"
539
+ fi
540
+ if [[ "$REVIEW_STATUS" -ne 0 ]]; then
541
+ fail_review "$FAIL_CLOSED" "review command exited $REVIEW_STATUS" "$DURATION_MS"
542
+ fi
543
+ if [[ -z "$REVIEW_OUTPUT" ]]; then
544
+ fail_review "$FAIL_CLOSED" "review returned empty output" "$DURATION_MS"
545
+ fi
546
+ if ! jq -e . >/dev/null 2>&1 <<<"$REVIEW_OUTPUT"; then
547
+ fail_review "$FAIL_CLOSED" "review returned unparseable JSON" "$DURATION_MS"
548
+ 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"
551
+ fi
552
+ NORMALIZED_REVIEW_OUTPUT="$(normalize_review_output "$REVIEW_OUTPUT" || true)"
553
+ if [[ -z "$NORMALIZED_REVIEW_OUTPUT" ]] || ! jq -e . >/dev/null 2>&1 <<<"$NORMALIZED_REVIEW_OUTPUT"; then
554
+ fail_review "$FAIL_CLOSED" "review returned unparseable JSON" "$DURATION_MS"
555
+ fi
556
+
557
+ if [[ -n "$CACHE_FILE" && -n "$DIFF_HASH" ]]; then
558
+ write_cache "$CACHE_FILE" "$DIFF_HASH" "$NORMALIZED_REVIEW_OUTPUT" "$DURATION_MS"
559
+ fi
560
+
561
+ evaluate_review_output "$NORMALIZED_REVIEW_OUTPUT" "sync" "$DURATION_MS"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "composer-mastermind",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Multi-agent orchestrator: Claude as brain, GLM/Codex/agy as executors. Dispatches code/research/review work to subagents wired through the @composer-mcp/server MCP server.",
5
5
  "claudeCodeVersion": ">=4.6",
6
6
  "requires": [
@@ -8,13 +8,15 @@
8
8
  ],
9
9
  "settings": {
10
10
  "PreToolUse": [
11
- "bash:hooks/boundary_guard.sh"
11
+ "bash:hooks/boundary_guard.sh",
12
+ "bash:hooks/precommit_codex_review.sh"
12
13
  ],
13
14
  "PostToolUse": [
14
15
  "bash:hooks/lint-on-save.sh"
15
16
  ],
16
17
  "Stop": [
17
- "bash:hooks/learn.sh"
18
+ "bash:hooks/learn.sh",
19
+ "bash:hooks/codex_warm_review.sh"
18
20
  ]
19
21
  },
20
22
  "skills": [
@@ -62,6 +62,25 @@ system — spend it on planning, not on raw worker output.
62
62
  | Claude review explicitly requested, or high-risk/security-sensitive second opinion | `composer_review_claude` directly after the default review gate |
63
63
  | Anything that mutates state outside the conversation (push, deploy, install) | Escalate to the user. Do not act. |
64
64
 
65
+ ## Codex rescue (second-opinion lane)
66
+
67
+ Use Codex rescue when the same bug has 2+ failed fix attempts, root-cause
68
+ diagnosis stalls, an architecture/design fork needs cheap cross-model insurance,
69
+ or the user asks for a second opinion.
70
+
71
+ - Read root `composer.config.json` `codexRescue`: `{enabled, mode, model}`.
72
+ Omitted means `enabled=true`, `mode=ask`, `model=gpt-5.4-mini`.
73
+ - If `enabled:false`, do not propose or dispatch rescue.
74
+ - If `mode:"ask"`, propose rescue to the user first. If `mode:"auto"`,
75
+ dispatch only within `spendAuthorization` caps.
76
+ - Route through the `codex:codex-rescue` subagent (Agent tool) or
77
+ `/codex:rescue` command.
78
+ - ALWAYS pass the configured model. Unpinned rescue defaults to `gpt-5.4`
79
+ at roughly 3x cost.
80
+ - Rescue prompt includes failing evidence only: error output, file:line refs,
81
+ latest failing command, changed files, and smallest repro. Do not include the
82
+ whole transcript, secrets, or `.env.json`.
83
+
65
84
  **Class-based route policy:** route by task class, not by a blanket
66
85
  "always dispatch" rule.
67
86
 
@@ -147,6 +166,40 @@ dispatch that hits a real-money provider (`anthropic`,
147
166
  CLI providers (`Codex`, `agy`) are billed separately by the user's own auth
148
167
  and do not count toward these caps. Mock providers are always free.
149
168
 
169
+ # Codex review gate (optional)
170
+
171
+ Optional cross-LLM review lane using the OpenAI `codex` Claude Code plugin:
172
+ a different model catches issues agy / Claude miss. OFF by default via
173
+ `composer.config.json` `codexReview.enabled`. Run `agent-composer doctor`
174
+ to check codex CLI, plugin availability, and current gate config.
175
+
176
+ Fire Codex review at composer's OWN trigger points. Do NOT enable the plugin's
177
+ global stop-gate; it fires on every stop.
178
+
179
+ - Before a commit you are about to make (`triggers.preCommit`): run
180
+ `codexReview.preCommitCommand` (omitted default `review`; repo template pins
181
+ `adversarial-review` for structured verdicts) on the working-tree diff.
182
+ - After a plan doc is written (`triggers.postPlan`): run
183
+ `codexReview.postPlanCommand` (default `adversarial-review`) on the plan
184
+ `.md` via focus text, challenging design before code is written.
185
+ - Invoke via Bash: resolve plugin root from `agent-composer doctor`, then run
186
+ `node <root>/scripts/codex-companion.mjs <command> --background --scope <scope> [--base <base>] [--model <codexReview.model>] [focus...]`.
187
+ - Default `execution: background`: launch with `run_in_background`, poll
188
+ `status` / `result`, parse review-output JSON, and surface ONLY `verdict`
189
+ plus one line per finding. Raw Codex output stays out.
190
+ - Honor `codexReview.mode`: `ask` -> AskUserQuestion once before running;
191
+ `auto` -> run within `spendAuthorization`.
192
+
193
+ ## Mechanical pre-commit gate
194
+
195
+ A stronger, optional enforcement: `codexReview.preCommitHook.enabled` turns the
196
+ PreToolUse hook `precommit_codex_review.sh` into a hard gate — a `git commit`
197
+ is DENIED when Codex review returns `needs-attention` with a finding at or above
198
+ `preCommitHook.blockOnSeverity` (default `high`). Fail-open by default
199
+ (`failClosed:false`): if Codex is unavailable the commit proceeds. Run
200
+ `agent-composer doctor` to see the gate state. This is mechanical (hook-enforced),
201
+ unlike the orchestrator-driven triggers above.
202
+
150
203
  # Headless invocation
151
204
 
152
205
  When composer-mastermind runs inside a headless `claude -p` (eval harness,