agent-composer 0.2.3 → 0.3.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.
- package/composer.config.schema.json +191 -0
- package/dist/cli/doctor.d.ts +21 -0
- package/dist/cli/doctor.js +383 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/init.js +34 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/config/schema.d.ts +165 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/AnthropicCompatibleProvider.js +17 -15
- package/dist/providers/AnthropicCompatibleProvider.js.map +1 -1
- package/dist/providers/CLIProvider.d.ts +5 -1
- package/dist/providers/CLIProvider.js +25 -8
- package/dist/providers/CLIProvider.js.map +1 -1
- package/dist/providers/IProvider.d.ts +1 -0
- package/dist/server.d.ts +7 -3
- package/dist/server.js +68 -18
- package/dist/server.js.map +1 -1
- package/dist/util/handoff.d.ts +2 -2
- package/package.json +1 -1
- package/plugin/composer-mastermind/hooks/codex_warm_review.sh +362 -0
- package/plugin/composer-mastermind/hooks/learn.sh +33 -12
- package/plugin/composer-mastermind/hooks/precommit_codex_review.sh +561 -0
- package/plugin/composer-mastermind/plugin.json +5 -3
- package/plugin/composer-mastermind/skills/composer-mastermind/SKILL.md +59 -0
|
@@ -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.
|
|
3
|
+
"version": "0.3.1",
|
|
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,31 @@ 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
|
+
When an edit targets a project other than the Composer MCP server cwd (for
|
|
66
|
+
example dotfiles or another repo), pass `projectDir: "<absolute path>"` to
|
|
67
|
+
`composer_code_cli` or `composer_code_chain`. Codex must trust that directory
|
|
68
|
+
(a git repo or a `config.toml` projects entry) or it will refuse; do not add
|
|
69
|
+
`--skip-git-repo-check`.
|
|
70
|
+
|
|
71
|
+
## Codex rescue (second-opinion lane)
|
|
72
|
+
|
|
73
|
+
Use Codex rescue when the same bug has 2+ failed fix attempts, root-cause
|
|
74
|
+
diagnosis stalls, an architecture/design fork needs cheap cross-model insurance,
|
|
75
|
+
or the user asks for a second opinion.
|
|
76
|
+
|
|
77
|
+
- Read root `composer.config.json` `codexRescue`: `{enabled, mode, model}`.
|
|
78
|
+
Omitted means `enabled=true`, `mode=ask`, `model=gpt-5.4-mini`.
|
|
79
|
+
- If `enabled:false`, do not propose or dispatch rescue.
|
|
80
|
+
- If `mode:"ask"`, propose rescue to the user first. If `mode:"auto"`,
|
|
81
|
+
dispatch only within `spendAuthorization` caps.
|
|
82
|
+
- Route through the `codex:codex-rescue` subagent (Agent tool) or
|
|
83
|
+
`/codex:rescue` command.
|
|
84
|
+
- ALWAYS pass the configured model. Unpinned rescue defaults to `gpt-5.4`
|
|
85
|
+
at roughly 3x cost.
|
|
86
|
+
- Rescue prompt includes failing evidence only: error output, file:line refs,
|
|
87
|
+
latest failing command, changed files, and smallest repro. Do not include the
|
|
88
|
+
whole transcript, secrets, or `.env.json`.
|
|
89
|
+
|
|
65
90
|
**Class-based route policy:** route by task class, not by a blanket
|
|
66
91
|
"always dispatch" rule.
|
|
67
92
|
|
|
@@ -147,6 +172,40 @@ dispatch that hits a real-money provider (`anthropic`,
|
|
|
147
172
|
CLI providers (`Codex`, `agy`) are billed separately by the user's own auth
|
|
148
173
|
and do not count toward these caps. Mock providers are always free.
|
|
149
174
|
|
|
175
|
+
# Codex review gate (optional)
|
|
176
|
+
|
|
177
|
+
Optional cross-LLM review lane using the OpenAI `codex` Claude Code plugin:
|
|
178
|
+
a different model catches issues agy / Claude miss. OFF by default via
|
|
179
|
+
`composer.config.json` `codexReview.enabled`. Run `agent-composer doctor`
|
|
180
|
+
to check codex CLI, plugin availability, and current gate config.
|
|
181
|
+
|
|
182
|
+
Fire Codex review at composer's OWN trigger points. Do NOT enable the plugin's
|
|
183
|
+
global stop-gate; it fires on every stop.
|
|
184
|
+
|
|
185
|
+
- Before a commit you are about to make (`triggers.preCommit`): run
|
|
186
|
+
`codexReview.preCommitCommand` (omitted default `review`; repo template pins
|
|
187
|
+
`adversarial-review` for structured verdicts) on the working-tree diff.
|
|
188
|
+
- After a plan doc is written (`triggers.postPlan`): run
|
|
189
|
+
`codexReview.postPlanCommand` (default `adversarial-review`) on the plan
|
|
190
|
+
`.md` via focus text, challenging design before code is written.
|
|
191
|
+
- Invoke via Bash: resolve plugin root from `agent-composer doctor`, then run
|
|
192
|
+
`node <root>/scripts/codex-companion.mjs <command> --background --scope <scope> [--base <base>] [--model <codexReview.model>] [focus...]`.
|
|
193
|
+
- Default `execution: background`: launch with `run_in_background`, poll
|
|
194
|
+
`status` / `result`, parse review-output JSON, and surface ONLY `verdict`
|
|
195
|
+
plus one line per finding. Raw Codex output stays out.
|
|
196
|
+
- Honor `codexReview.mode`: `ask` -> AskUserQuestion once before running;
|
|
197
|
+
`auto` -> run within `spendAuthorization`.
|
|
198
|
+
|
|
199
|
+
## Mechanical pre-commit gate
|
|
200
|
+
|
|
201
|
+
A stronger, optional enforcement: `codexReview.preCommitHook.enabled` turns the
|
|
202
|
+
PreToolUse hook `precommit_codex_review.sh` into a hard gate — a `git commit`
|
|
203
|
+
is DENIED when Codex review returns `needs-attention` with a finding at or above
|
|
204
|
+
`preCommitHook.blockOnSeverity` (default `high`). Fail-open by default
|
|
205
|
+
(`failClosed:false`): if Codex is unavailable the commit proceeds. Run
|
|
206
|
+
`agent-composer doctor` to see the gate state. This is mechanical (hook-enforced),
|
|
207
|
+
unlike the orchestrator-driven triggers above.
|
|
208
|
+
|
|
150
209
|
# Headless invocation
|
|
151
210
|
|
|
152
211
|
When composer-mastermind runs inside a headless `claude -p` (eval harness,
|