agent-composer 0.2.3 → 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,362 @@
1
+ #!/usr/bin/env bash
2
+ # Optional Codex warm-cache Stop hook.
3
+ # Fail-safe: every path exits 0 and never blocks session end.
4
+
5
+ set -u
6
+
7
+ RUN_LOG="/tmp/composer-codex-review-log.jsonl"
8
+
9
+ composer_disabled() {
10
+ case "${COMPOSER_ENABLED:-}" in
11
+ 0|false|FALSE|off|OFF|no|NO) return 0 ;;
12
+ esac
13
+ case "${COMPOSER_DISABLED:-}" in
14
+ 1|true|TRUE|on|ON|yes|YES) return 0 ;;
15
+ esac
16
+ if [[ -n "${COMPOSER_DISABLED_FILE:-}" && -e "$COMPOSER_DISABLED_FILE" ]]; then
17
+ return 0
18
+ fi
19
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" && -e "$CLAUDE_PROJECT_DIR/.composer-disabled" ]]; then
20
+ return 0
21
+ fi
22
+ if [[ -n "${HOME:-}" && -e "$HOME/.claude/composer.disabled" ]]; then
23
+ return 0
24
+ fi
25
+ return 1
26
+ }
27
+
28
+ hash_stdin_16() {
29
+ if command -v shasum >/dev/null 2>&1; then
30
+ shasum -a 256 | awk '{print substr($1,1,16)}'
31
+ elif command -v sha256sum >/dev/null 2>&1; then
32
+ sha256sum | awk '{print substr($1,1,16)}'
33
+ else
34
+ return 1
35
+ fi
36
+ }
37
+
38
+ compute_repo_hash() {
39
+ printf '%s' "$1" | hash_stdin_16
40
+ }
41
+
42
+ ensure_state_dir() {
43
+ local dir="${COMPOSER_STATE_DIR:-${HOME:-}/.cache/composer}"
44
+ [[ -n "$dir" ]] || return 1
45
+ mkdir -p "$dir" 2>/dev/null || return 1
46
+ chmod 700 "$dir" 2>/dev/null || return 1
47
+ [[ -d "$dir" ]] || return 1
48
+ printf '%s\n' "$dir"
49
+ }
50
+
51
+ cache_is_fresh_match() {
52
+ local cache_file="$1"
53
+ local diff_hash="$2"
54
+ local max_age_minutes="$3"
55
+ [[ -f "$cache_file" ]] || return 1
56
+ jq -e --arg hash "$diff_hash" --argjson maxAge "$max_age_minutes" '
57
+ (.hash == $hash)
58
+ and ((.ts | type) == "string")
59
+ and ((now - (.ts | fromdateiso8601)) <= ($maxAge * 60))
60
+ ' "$cache_file" >/dev/null 2>&1
61
+ }
62
+
63
+ compute_diff_hash() {
64
+ local root="$1"
65
+ local pre_commit_command="$2"
66
+ local scope="$3"
67
+ local base="$4"
68
+ local model="$5"
69
+ local base_ref merge_base branch_diff
70
+ # blockOnSeverity is excluded because threshold is re-applied at gate-read time from the cached findings array.
71
+ {
72
+ git -C "$root" diff HEAD 2>/dev/null
73
+ git -C "$root" diff --cached 2>/dev/null
74
+ if [[ "$scope" == "branch" ]]; then
75
+ if base_ref="$(git -C "$root" rev-parse --verify "${base}^{commit}" 2>/dev/null)" \
76
+ && merge_base="$(git -C "$root" merge-base "$base" HEAD 2>/dev/null)" \
77
+ && branch_diff="$(git -C "$root" diff "$base...HEAD" 2>/dev/null)"; then
78
+ printf '\ncomposer-codex-review-branch\nbaseRef=%s\nmergeBase=%s\n' "$base_ref" "$merge_base"
79
+ printf '%s' "$branch_diff"
80
+ printf '\n'
81
+ fi
82
+ fi
83
+ printf '\ncomposer-codex-review-policy\npreCommitCommand=%s\nscope=%s\nbase=%s\nmodel=%s\n' "$pre_commit_command" "$scope" "$base" "$model"
84
+ } | hash_stdin_16
85
+ }
86
+
87
+ find_git_root() {
88
+ local start="${CLAUDE_PROJECT_DIR:-.}"
89
+ git -C "$start" rev-parse --show-toplevel 2>/dev/null || git rev-parse --show-toplevel 2>/dev/null
90
+ }
91
+
92
+ find_codex_plugin_root() {
93
+ if [[ -n "${COMPOSER_CODEX_PLUGIN_ROOT:-}" && -f "$COMPOSER_CODEX_PLUGIN_ROOT/scripts/codex-companion.mjs" ]]; then
94
+ printf '%s\n' "$COMPOSER_CODEX_PLUGIN_ROOT"
95
+ return 0
96
+ fi
97
+
98
+ local marketplace_root="${HOME:-}/.claude/plugins/marketplaces/openai-codex/plugins/codex"
99
+ if [[ -f "$marketplace_root/scripts/codex-companion.mjs" ]]; then
100
+ printf '%s\n' "$marketplace_root"
101
+ return 0
102
+ fi
103
+
104
+ local cache_base="${HOME:-}/.claude/plugins/cache/openai-codex/codex"
105
+ [[ -d "$cache_base" ]] || return 1
106
+
107
+ local versions
108
+ if versions="$(find "$cache_base" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null)"; then
109
+ if printf '%s\n' "$versions" | sort -V >/dev/null 2>&1; then
110
+ versions="$(printf '%s\n' "$versions" | sort -V)"
111
+ else
112
+ versions="$(printf '%s\n' "$versions" | sort)"
113
+ fi
114
+ local version
115
+ while IFS= read -r version; do
116
+ [[ -n "$version" ]] || continue
117
+ if [[ -f "$cache_base/$version/scripts/codex-companion.mjs" ]]; then
118
+ printf '%s\n' "$cache_base/$version"
119
+ fi
120
+ done <<<"$versions" | tail -n 1
121
+ fi
122
+ }
123
+
124
+ if composer_disabled; then
125
+ exit 0
126
+ fi
127
+
128
+ cat >/dev/null || true
129
+
130
+ if ! command -v jq >/dev/null 2>&1; then
131
+ exit 0
132
+ fi
133
+
134
+ CONFIG_PATH="${COMPOSER_CONFIG:-${CLAUDE_PROJECT_DIR:-.}/composer.config.json}"
135
+ [[ -f "$CONFIG_PATH" ]] || exit 0
136
+ CONFIG_JSON="$(cat "$CONFIG_PATH" 2>/dev/null || true)"
137
+ if [[ -z "$CONFIG_JSON" ]] || ! jq -e . >/dev/null 2>&1 <<<"$CONFIG_JSON"; then
138
+ exit 0
139
+ fi
140
+
141
+ ENABLED="$(jq -r '.codexReview.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
142
+ PRECOMMIT_HOOK_ENABLED="$(jq -r '.codexReview.preCommitHook.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
143
+ WARM_CACHE_ENABLED="$(jq -r '.codexReview.warmCache.enabled // false' <<<"$CONFIG_JSON" 2>/dev/null || printf 'false')"
144
+ if [[ "$ENABLED" != "true" || "$PRECOMMIT_HOOK_ENABLED" != "true" || "$WARM_CACHE_ENABLED" != "true" ]]; then
145
+ exit 0
146
+ fi
147
+
148
+ GIT_ROOT="$(find_git_root || true)"
149
+ [[ -n "$GIT_ROOT" ]] || exit 0
150
+ cd "$GIT_ROOT" 2>/dev/null || exit 0
151
+ [[ -n "$(git status --porcelain 2>/dev/null)" ]] || exit 0
152
+
153
+ REVIEW_COMMAND="$(jq -r '.codexReview.preCommitCommand // "review"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'review')"
154
+ SCOPE="$(jq -r '.codexReview.scope // "working-tree"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'working-tree')"
155
+ BASE="$(jq -r '.codexReview.base // "main"' <<<"$CONFIG_JSON" 2>/dev/null || printf 'main')"
156
+ CODEX_MODEL="$(jq -r '.codexReview.model // empty' <<<"$CONFIG_JSON" 2>/dev/null || true)"
157
+ TIMEOUT_MS="$(jq -r '.codexReview.warmCache.timeoutMs // 300000' <<<"$CONFIG_JSON" 2>/dev/null || printf '300000')"
158
+ WARM_CACHE_MAX_AGE_MINUTES="$(jq -r '.codexReview.warmCache.maxAgeMinutes // 30' <<<"$CONFIG_JSON" 2>/dev/null || printf '30')"
159
+ case "$REVIEW_COMMAND" in
160
+ review|adversarial-review) ;;
161
+ *) exit 0 ;;
162
+ esac
163
+ case "$TIMEOUT_MS" in
164
+ ''|*[!0-9]*) TIMEOUT_MS=300000 ;;
165
+ esac
166
+ case "$WARM_CACHE_MAX_AGE_MINUTES" in
167
+ ''|*[!0-9]*) WARM_CACHE_MAX_AGE_MINUTES=30 ;;
168
+ esac
169
+ TIMEOUT_SECONDS=$(( (TIMEOUT_MS + 999) / 1000 ))
170
+ if [[ "$TIMEOUT_SECONDS" -lt 1 ]]; then
171
+ TIMEOUT_SECONDS=1
172
+ fi
173
+
174
+ DIFF_HASH="$(compute_diff_hash "$GIT_ROOT" "$REVIEW_COMMAND" "$SCOPE" "$BASE" "$CODEX_MODEL" 2>/dev/null || true)"
175
+ REPO_HASH="$(compute_repo_hash "$GIT_ROOT" 2>/dev/null || true)"
176
+ [[ -n "$DIFF_HASH" && -n "$REPO_HASH" ]] || exit 0
177
+
178
+ STATE_DIR="$(ensure_state_dir 2>/dev/null || true)"
179
+ [[ -n "$STATE_DIR" ]] || exit 0
180
+
181
+ CACHE_FILE="$STATE_DIR/codex-review-cache-${REPO_HASH}.json"
182
+ LOCK_FILE="$STATE_DIR/codex-warm-${REPO_HASH}.lock"
183
+ if cache_is_fresh_match "$CACHE_FILE" "$DIFF_HASH" "$WARM_CACHE_MAX_AGE_MINUTES"; then
184
+ exit 0
185
+ fi
186
+ if [[ -f "$LOCK_FILE" ]]; then
187
+ LOCK_PID="$(cat "$LOCK_FILE" 2>/dev/null || true)"
188
+ if [[ "$LOCK_PID" =~ ^[0-9]+$ ]] && kill -0 "$LOCK_PID" 2>/dev/null; then
189
+ exit 0
190
+ fi
191
+ rm -f "$LOCK_FILE" 2>/dev/null || true
192
+ fi
193
+
194
+ CODEX_ROOT="$(find_codex_plugin_root || true)"
195
+ [[ -n "$CODEX_ROOT" ]] || exit 0
196
+
197
+ spawn_warm_child() {
198
+ local child_script
199
+ child_script="$(mktemp "${TMPDIR:-/tmp}/composer-warm-child.XXXXXX" 2>/dev/null)" || return 0
200
+ if ! cat > "$child_script" <<'COMPOSER_WARM_CHILD'
201
+ #!/usr/bin/env bash
202
+ set -u
203
+ codex_root="$1"
204
+ review_command="$2"
205
+ scope="$3"
206
+ base="$4"
207
+ timeout_seconds="$5"
208
+ cache_file="$6"
209
+ lock_file="$7"
210
+ diff_hash="$8"
211
+ run_log="$9"
212
+ review_model="${10}"
213
+
214
+ run_reviewer() {
215
+ local timeout_seconds="$1"
216
+ shift
217
+ if [[ "${COMPOSER_FORCE_BASH_TIMEOUT:-}" != "1" ]] && command -v timeout >/dev/null 2>&1; then
218
+ timeout "$timeout_seconds" "$@"
219
+ return $?
220
+ fi
221
+ if [[ "${COMPOSER_FORCE_BASH_TIMEOUT:-}" != "1" ]] && command -v gtimeout >/dev/null 2>&1; then
222
+ gtimeout "$timeout_seconds" "$@"
223
+ return $?
224
+ fi
225
+
226
+ local pid watchdog status marker
227
+ marker="${TMPDIR:-/tmp}/composer-timeout.$$.$RANDOM"
228
+ "$@" &
229
+ pid=$!
230
+ (
231
+ cleanup_sleeper() {
232
+ if [[ -n "${sleeper:-}" ]]; then
233
+ kill "$sleeper" 2>/dev/null || true
234
+ fi
235
+ exit 0
236
+ }
237
+ sleeper=""
238
+ trap cleanup_sleeper TERM INT
239
+ sleep "$timeout_seconds" &
240
+ sleeper=$!
241
+ wait "$sleeper" 2>/dev/null || exit 0
242
+ printf "1" >"$marker" 2>/dev/null || true
243
+ kill -TERM "$pid" 2>/dev/null || true
244
+ sleep 5
245
+ kill -KILL "$pid" 2>/dev/null || true
246
+ ) &
247
+ watchdog=$!
248
+ wait "$pid"
249
+ status=$?
250
+ kill "$watchdog" 2>/dev/null || true
251
+ wait "$watchdog" 2>/dev/null || true
252
+ if [[ -f "$marker" ]]; then
253
+ rm -f "$marker" 2>/dev/null || true
254
+ return 124
255
+ fi
256
+ rm -f "$marker" 2>/dev/null || true
257
+ return "$status"
258
+ }
259
+
260
+ append_run_log() {
261
+ local verdict="$1"
262
+ local duration_ms="$2"
263
+ local findings="$3"
264
+ jq -nc \
265
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
266
+ --arg verdict "$verdict" \
267
+ --arg source "warm" \
268
+ --arg scope "$scope" \
269
+ --arg diff_hash "$diff_hash" \
270
+ --argjson duration_ms "${duration_ms:-0}" \
271
+ --argjson findings "${findings:-0}" \
272
+ "{ts:\$ts,verdict:\$verdict,decision:\"skip\",source:\$source,duration_ms:\$duration_ms,findings:\$findings,scope:\$scope,diff_hash:\$diff_hash}" \
273
+ >> "$run_log" 2>/dev/null || true
274
+ }
275
+
276
+ cleanup() {
277
+ local lock_owner
278
+ lock_owner="$(head -n 1 "$lock_file" 2>/dev/null || true)"
279
+ if [[ "$lock_owner" == "$$" ]]; then
280
+ rm -f "$lock_file" 2>/dev/null || true
281
+ fi
282
+ rm -f "$0" 2>/dev/null || true
283
+ }
284
+ trap cleanup EXIT
285
+ printf "%s\n" "$$" > "$lock_file" 2>/dev/null || true
286
+
287
+ args=("node" "$codex_root/scripts/codex-companion.mjs" "$review_command" "--wait" "--json" "--scope" "$scope")
288
+ if [[ "$scope" == "branch" ]]; then
289
+ args+=("--base" "$base")
290
+ fi
291
+ if [[ -n "$review_model" ]]; then
292
+ args+=("--model" "$review_model")
293
+ fi
294
+ start_seconds="$(date +%s)"
295
+ output="$(run_reviewer "$timeout_seconds" "${args[@]}" 2>/dev/null)"
296
+ status=$?
297
+ end_seconds="$(date +%s)"
298
+ duration_ms=$(( (end_seconds - start_seconds) * 1000 ))
299
+ if [[ "$status" -ne 0 || -z "$output" ]] || ! jq -e . >/dev/null 2>&1 <<<"$output"; then
300
+ append_run_log "skip" "$duration_ms" 0
301
+ exit 0
302
+ fi
303
+ if jq -e "(.parseError // null) as \$error | (\$error != null and \$error != false and ((\$error | tostring) | length) > 0)" >/dev/null 2>&1 <<<"$output"; then
304
+ append_run_log "skip" "$duration_ms" 0
305
+ exit 0
306
+ fi
307
+ normalized="$(jq -c "
308
+ def array_or_empty(\$value): if (\$value | type) == \"array\" then \$value else [] end;
309
+ {
310
+ verdict: (.result.verdict // .verdict // null),
311
+ summary: (.result.summary // .summary // \"\"),
312
+ findings: array_or_empty(.result.findings // .findings // []),
313
+ next_steps: array_or_empty(.result.next_steps // .next_steps // [])
314
+ }
315
+ " <<<"$output" 2>/dev/null || true)"
316
+ if [[ -z "$normalized" ]] || ! jq -e . >/dev/null 2>&1 <<<"$normalized"; then
317
+ append_run_log "skip" "$duration_ms" 0
318
+ exit 0
319
+ fi
320
+ findings="$(jq -r "if (.findings | type) == \"array\" then (.findings | length) else 0 end" <<<"$normalized" 2>/dev/null || printf "0")"
321
+ verdict="$(jq -r ".verdict // \"skip\"" <<<"$normalized" 2>/dev/null || printf "skip")"
322
+ case "$verdict" in
323
+ approve|needs-attention)
324
+ tmp="${cache_file}.$$"
325
+ jq -nc \
326
+ --arg hash "$diff_hash" \
327
+ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
328
+ --argjson review "$normalized" \
329
+ --argjson durationMs "$duration_ms" \
330
+ "{hash:\$hash, verdict:(\$review.verdict // \"error\"), summary:(\$review.summary // \"\"), findings:(if (\$review.findings | type) == \"array\" then \$review.findings else [] end), ts:\$ts, durationMs:\$durationMs}" \
331
+ > "$tmp" 2>/dev/null && chmod 600 "$tmp" 2>/dev/null && mv "$tmp" "$cache_file" 2>/dev/null || rm -f "$tmp"
332
+ ;;
333
+ esac
334
+ append_run_log "$verdict" "$duration_ms" "$findings"
335
+ COMPOSER_WARM_CHILD
336
+ then
337
+ rm -f "$child_script" 2>/dev/null || true
338
+ return 0
339
+ fi
340
+ chmod +x "$child_script" 2>/dev/null || true
341
+ if ! printf "%s\n" "$$" > "$LOCK_FILE" 2>/dev/null; then
342
+ rm -f "$child_script" 2>/dev/null || true
343
+ return 0
344
+ fi
345
+ nohup bash "$child_script" "$CODEX_ROOT" "$REVIEW_COMMAND" "$SCOPE" "$BASE" "$TIMEOUT_SECONDS" "$CACHE_FILE" "$LOCK_FILE" "$DIFF_HASH" "$RUN_LOG" "$CODEX_MODEL" >/dev/null 2>&1 &
346
+ local bg_pid=$!
347
+ if [[ -z "${bg_pid:-}" ]]; then
348
+ local lock_owner
349
+ lock_owner="$(head -n 1 "$LOCK_FILE" 2>/dev/null || true)"
350
+ if [[ "$lock_owner" == "$$" ]]; then
351
+ rm -f "$LOCK_FILE" 2>/dev/null || true
352
+ fi
353
+ rm -f "$child_script" 2>/dev/null || true
354
+ return 0
355
+ fi
356
+ disown "$bg_pid" 2>/dev/null || true
357
+ return 0
358
+ }
359
+
360
+ spawn_warm_child || true
361
+
362
+ exit 0
@@ -58,17 +58,38 @@ mkdir -p "$LEARN_DIR" 2>/dev/null || exit 0
58
58
  TRIGGER='(?i)\b(no|don.t|do not|wrong|stop|actually|instead|never|please don.t)\b'
59
59
 
60
60
  # Anthropic transcripts are JSONL (one event per line). Filter user-role
61
- # events whose content matches the trigger regex, then append a short
62
- # bullet per match. Truncate to 240 chars to keep the log scannable.
63
- {
64
- printf '\n## Session ended %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
65
- jq -r --arg trig "$TRIGGER" '
66
- select(.role == "user" or .type == "user")
67
- | (.content // .message // "") as $raw
68
- | (if ($raw | type) == "array" then ($raw | map(.text // "") | join(" ")) else ($raw | tostring) end) as $text
69
- | select($text | test($trig))
70
- | "- " + ($text | gsub("\\s+"; " ") | .[0:240])
71
- ' "$TRANSCRIPT_PATH" 2>/dev/null
72
- } >> "$OUT" 2>/dev/null || true
61
+ # events whose content matches the trigger regex, then append new short
62
+ # bullets only. Truncate to 400 chars to keep the log scannable.
63
+ MATCHES="$(mktemp -t composer_learnings.XXXXXX)" || exit 0
64
+ jq -r --arg trig "$TRIGGER" '
65
+ select(.role == "user" or .type == "user")
66
+ | (.content // .message // "") as $raw
67
+ | (if ($raw | type) == "array" then ($raw | map(.text // "") | join(" ")) else ($raw | tostring) end) as $text
68
+ | select($text | test($trig))
69
+ | "- " + ($text | gsub("\\s+"; " ") | .[0:400])
70
+ ' "$TRANSCRIPT_PATH" 2>/dev/null > "$MATCHES" || {
71
+ rm -f "$MATCHES"
72
+ exit 0
73
+ }
74
+
75
+ NEW_MATCHES="$(mktemp -t composer_learnings_new.XXXXXX)" || {
76
+ rm -f "$MATCHES"
77
+ exit 0
78
+ }
79
+ while IFS= read -r line; do
80
+ [[ -n "$line" ]] || continue
81
+ if [[ ! -f "$OUT" ]] || ! grep -Fxq -- "$line" "$OUT" 2>/dev/null; then
82
+ printf '%s\n' "$line" >> "$NEW_MATCHES"
83
+ fi
84
+ done < "$MATCHES"
85
+
86
+ if [[ -s "$NEW_MATCHES" ]]; then
87
+ {
88
+ printf '\n## Session ended %s\n\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
89
+ cat "$NEW_MATCHES"
90
+ } >> "$OUT" 2>/dev/null || true
91
+ fi
92
+
93
+ rm -f "$MATCHES" "$NEW_MATCHES"
73
94
 
74
95
  exit 0