@ikon85/agent-workflow-kit 0.28.0 → 0.29.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 (33) hide show
  1. package/.agents/skills/board-to-waves/SKILL.md +13 -3
  2. package/.agents/skills/to-issues/SKILL.md +102 -27
  3. package/.agents/skills/to-prd/SKILL.md +13 -3
  4. package/.agents/skills/to-waves/SKILL.md +16 -4
  5. package/.agents/skills/wrapup/SKILL.md +0 -1
  6. package/.claude/skills/board-to-waves/SKILL.md +13 -3
  7. package/.claude/skills/codex-build/SKILL.md +69 -23
  8. package/.claude/skills/codex-review/SKILL.md +47 -37
  9. package/.claude/skills/grill-me-codex/SKILL.md +45 -34
  10. package/.claude/skills/grill-with-docs-codex/SKILL.md +46 -34
  11. package/.claude/skills/to-issues/SKILL.md +102 -27
  12. package/.claude/skills/to-prd/SKILL.md +13 -3
  13. package/.claude/skills/to-waves/SKILL.md +16 -4
  14. package/.claude/skills/wrapup/SKILL.md +0 -1
  15. package/README.md +27 -0
  16. package/agent-workflow-kit.package.json +56 -16
  17. package/docs/research/wave-152-consumer-acceptance.md +98 -0
  18. package/package.json +1 -1
  19. package/scripts/board-sync.py +3 -7
  20. package/scripts/build-kit.test.mjs +42 -1
  21. package/scripts/codex-exec-scenarios/fake-codex.mjs +152 -0
  22. package/scripts/codex-exec.sh +566 -0
  23. package/scripts/codex-exec.test.mjs +815 -0
  24. package/scripts/codex_proc.py +602 -0
  25. package/scripts/find-by-marker.py +68 -0
  26. package/scripts/marker_lib.py +88 -0
  27. package/scripts/release-state.test.mjs +53 -1
  28. package/scripts/render-anchor.py +111 -0
  29. package/scripts/test_marker_lib.py +154 -0
  30. package/scripts/test_render_anchor.py +267 -0
  31. package/scripts/test_retro_wrapup_contract.py +6 -0
  32. package/scripts/test_skill_codex_exec_lifecycle.py +123 -0
  33. package/src/lib/bundle.mjs +10 -0
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env bash
2
+ set -u
3
+
4
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+ PROC_HELPER="$SCRIPT_DIR/codex_proc.py"
6
+ TESTED_VERSIONS=("0.137.0" "0.144.6")
7
+ STATE_ROOT=${CODEX_EXEC_STATE_ROOT:-${TMPDIR:-/tmp}/codex-exec-state}
8
+
9
+ emit_json() {
10
+ python3 - "$@" <<'PY'
11
+ import json, sys
12
+ fields = {}
13
+ for pair in sys.argv[1:]:
14
+ key, value = pair.split("=", 1)
15
+ fields[key] = value
16
+ print(json.dumps(fields, sort_keys=True))
17
+ PY
18
+ }
19
+
20
+ fail() {
21
+ local error=$1 message=$2 status=${3:-EXEC_FAILED}
22
+ emit_json "status=$status" "error=$error" "message=$message"
23
+ return 1
24
+ }
25
+
26
+ fail_process() {
27
+ local error=$1 message=$2 status=$3 process_status=$4
28
+ python3 - "$error" "$message" "$status" "$process_status" <<'PY'
29
+ import json
30
+ import signal
31
+ import sys
32
+
33
+ error, message, status, raw_status = sys.argv[1:]
34
+ process_status = int(raw_status)
35
+ exit_status = process_status
36
+ signal_name = None
37
+ if process_status >= 128:
38
+ try:
39
+ signal_name = signal.Signals(process_status - 128).name
40
+ exit_status = None
41
+ except ValueError:
42
+ pass
43
+ print(json.dumps({
44
+ "status": status,
45
+ "error": error,
46
+ "message": message,
47
+ "originalExitStatus": exit_status,
48
+ "signal": signal_name,
49
+ }, sort_keys=True))
50
+ PY
51
+ return 1
52
+ }
53
+
54
+ positive_number() {
55
+ python3 - "$1" <<'PY' >/dev/null 2>&1
56
+ import math, sys
57
+ try:
58
+ value = float(sys.argv[1])
59
+ assert math.isfinite(value) and value > 0
60
+ except (ValueError, AssertionError):
61
+ raise SystemExit(1)
62
+ PY
63
+ }
64
+
65
+ nonnegative_integer() {
66
+ [[ $1 =~ ^[0-9]+$ ]]
67
+ }
68
+
69
+ find_state() {
70
+ local run_id=$1
71
+ [[ $run_id =~ ^[A-Za-z0-9]+$ ]] || return 1
72
+ local candidate="$STATE_ROOT/codex-exec.$run_id"
73
+ [[ -d $candidate && -f $candidate/run-id ]] || return 1
74
+ [[ $(<"$candidate/run-id") == "$run_id" ]] || return 1
75
+ printf '%s\n' "$candidate"
76
+ }
77
+
78
+ preflight() {
79
+ local codex_bin=$1 quiet=${2:-false} version_text version auth auth_rc help resume_help platform allowed=false
80
+ if [[ ! -x $codex_bin ]] && ! command -v "$codex_bin" >/dev/null 2>&1; then
81
+ fail CODEX_NOT_FOUND "Codex executable not found"
82
+ return 1
83
+ fi
84
+ version_text=$("$codex_bin" --version 2>&1) || {
85
+ fail VERSION_CHECK_FAILED "Unable to read Codex version"
86
+ return 1
87
+ }
88
+ version=${version_text##* }
89
+ for tested in "${TESTED_VERSIONS[@]}"; do
90
+ [[ $version == "$tested" ]] && allowed=true
91
+ done
92
+ if [[ $allowed != true ]]; then
93
+ fail UNTESTED_VERSION "Codex version is not in the exact tested allowlist"
94
+ return 1
95
+ fi
96
+ platform=$(uname -s)
97
+ if [[ $platform != Linux && $platform != Darwin ]] || ! python3 -c 'import os; assert hasattr(os, "setsid")' >/dev/null 2>&1; then
98
+ fail MISSING_CAPABILITY "Platform cannot create an owned process group"
99
+ return 1
100
+ fi
101
+ help=$("$codex_bin" exec --help 2>&1) || true
102
+ if [[ $help != *--json* || $help != *--sandbox* || $help != *resume* ]]; then
103
+ fail MISSING_CAPABILITY "Codex exec lacks required JSON, sandbox, or resume capability"
104
+ return 1
105
+ fi
106
+ resume_help=$("$codex_bin" exec resume --help 2>&1) || true
107
+ if [[ $resume_help != *--config* || $resume_help != *--json* ]]; then
108
+ fail MISSING_CAPABILITY "Codex exec resume lacks required config or JSON capability"
109
+ return 1
110
+ fi
111
+ auth=$("$codex_bin" login status 2>&1)
112
+ auth_rc=$?
113
+ if ((auth_rc)); then
114
+ fail_process AUTH_REQUIRED "$auth" AUTH "$auth_rc"
115
+ return 1
116
+ fi
117
+ [[ $quiet == true ]] || emit_json status=OK "version=$version" "auth=$auth" "platform=$platform"
118
+ }
119
+
120
+ parse_options() {
121
+ CODEX_BIN=codex PROFILE= MODE= SANDBOX= PROMPT= PROMPT_FILE= RUN_ID= TIMEOUT= PROBE_TIMEOUT= DEBUG_RETAIN=false
122
+ while (($#)); do
123
+ case $1 in
124
+ --codex-bin|--profile|--mode|--prompt|--prompt-file|--run-id|--timeout|--probe-timeout)
125
+ (($# >= 2)) || { fail INVALID_ARGUMENT "Missing value for $1"; return 1; }
126
+ case $1 in
127
+ --codex-bin) CODEX_BIN=$2 ;;
128
+ --profile) PROFILE=$2 ;;
129
+ --mode) MODE=$2 ;;
130
+ --prompt) PROMPT=$2 ;;
131
+ --prompt-file) PROMPT_FILE=$2 ;;
132
+ --run-id) RUN_ID=$2 ;;
133
+ --timeout) TIMEOUT=$2 ;;
134
+ --probe-timeout) PROBE_TIMEOUT=$2 ;;
135
+ esac
136
+ shift 2 ;;
137
+ --debug-retain) DEBUG_RETAIN=true; shift ;;
138
+ *) fail INVALID_ARGUMENT "Unknown option: $1"; return 1 ;;
139
+ esac
140
+ done
141
+ }
142
+
143
+ prepare_prompt() {
144
+ local state_dir=$1
145
+ TEMP_PROMPT="$state_dir/.prompt-input"
146
+ if [[ -n $PROMPT_FILE ]]; then
147
+ [[ -f $PROMPT_FILE ]] || { fail PROMPT_NOT_FOUND "Prompt file not found"; return 1; }
148
+ cp "$PROMPT_FILE" "$TEMP_PROMPT"
149
+ elif [[ -n $PROMPT ]]; then
150
+ printf '%s' "$PROMPT" >"$TEMP_PROMPT"
151
+ else
152
+ fail PROMPT_REQUIRED "A prompt or prompt file is required"
153
+ return 1
154
+ fi
155
+ chmod 600 "$TEMP_PROMPT"
156
+ }
157
+
158
+ launch_round() {
159
+ local state_dir=$1 round=$2 lease_token=$3 thread_id=${4:-} rc
160
+ prepare_prompt "$state_dir" || return 1
161
+ local command
162
+ if [[ -z $thread_id ]]; then
163
+ command=("$CODEX_BIN" exec --json --sandbox "$SANDBOX" -)
164
+ else
165
+ command=("$CODEX_BIN" exec resume "$thread_id" -c "sandbox_mode=$SANDBOX" --json -)
166
+ fi
167
+ python3 "$PROC_HELPER" run --state-dir "$state_dir" --round "$round" \
168
+ --profile "$PROFILE" --sandbox "$SANDBOX" --timeout "$TIMEOUT" \
169
+ --probe-timeout "$PROBE_TIMEOUT" --prompt-file "$TEMP_PROMPT" \
170
+ --lease-token "$lease_token" -- "${command[@]}"
171
+ rc=$?
172
+ rm -f "$TEMP_PROMPT"
173
+ return "$rc"
174
+ }
175
+
176
+ acquire_lease() {
177
+ python3 "$PROC_HELPER" lease-acquire --state-dir "$1"
178
+ }
179
+
180
+ release_lease() {
181
+ python3 "$PROC_HELPER" lease-release --state-dir "$1" --token "$2" >/dev/null
182
+ }
183
+
184
+ release_or_fail() {
185
+ local state_dir=$1 lease_token=$2 run_id=$3 original=${4:-} release_rc
186
+ release_lease "$state_dir" "$lease_token" && return 0
187
+ release_rc=$?
188
+ python3 - "$original" "$run_id" "$release_rc" <<'PY'
189
+ import json
190
+ import signal
191
+ import sys
192
+
193
+ raw, run_id, raw_rc = sys.argv[1:]
194
+ try:
195
+ output = json.loads(raw)
196
+ if not isinstance(output, dict) or not isinstance(output.get("status"), str):
197
+ raise ValueError
198
+ except (json.JSONDecodeError, ValueError):
199
+ rc = int(raw_rc)
200
+ exit_status = rc
201
+ signal_name = None
202
+ if rc >= 128:
203
+ try:
204
+ signal_name = signal.Signals(rc - 128).name
205
+ exit_status = None
206
+ except ValueError:
207
+ pass
208
+ output = {
209
+ "status": "EXEC_FAILED",
210
+ "error": "LEASE_RELEASE_FAILED",
211
+ "message": "Run lease could not be released",
212
+ "runId": run_id,
213
+ "originalExitStatus": exit_status,
214
+ "signal": signal_name,
215
+ }
216
+ else:
217
+ output["cleanupStatus"] = "FAILED"
218
+ output["cleanupError"] = "LEASE_RELEASE_FAILED"
219
+ output["cleanupMessage"] = "Run lease could not be released"
220
+ print(json.dumps(output, sort_keys=True))
221
+ PY
222
+ return 1
223
+ }
224
+
225
+ supervisor_failed() {
226
+ local run_id=$1 process_status=$2
227
+ python3 - "$run_id" "$process_status" <<'PY'
228
+ import json
229
+ import signal
230
+ import sys
231
+
232
+ run_id, raw_status = sys.argv[1:]
233
+ process_status = int(raw_status)
234
+ exit_status = process_status
235
+ signal_name = None
236
+ if process_status >= 128:
237
+ try:
238
+ signal_name = signal.Signals(process_status - 128).name
239
+ exit_status = None
240
+ except ValueError:
241
+ pass
242
+ print(json.dumps({
243
+ "status": "EXEC_FAILED",
244
+ "error": "ROUND_SUPERVISOR_FAILED",
245
+ "message": "Round supervisor exited without structured output",
246
+ "runId": run_id,
247
+ "originalExitStatus": exit_status,
248
+ "signal": signal_name,
249
+ }, sort_keys=True))
250
+ PY
251
+ }
252
+
253
+ new_run() {
254
+ parse_options "$@" || return 1
255
+ PROFILE=${PROFILE:-review}
256
+ MODE=${MODE:-read-only}
257
+ [[ $PROFILE == review || $PROFILE == build ]] || { fail INVALID_PROFILE "Profile must be review or build"; return 1; }
258
+ [[ $MODE != danger-full-access ]] || {
259
+ fail DANGER_FULL_ACCESS_REJECTED "danger-full-access is not a supported lifecycle mode"; return 1;
260
+ }
261
+ [[ $MODE == read-only || $MODE == workspace-write ]] || {
262
+ fail INVALID_MODE "Mode must be read-only or workspace-write"; return 1;
263
+ }
264
+ SANDBOX=$MODE
265
+ [[ -n $PROMPT || -n $PROMPT_FILE ]] || { fail PROMPT_REQUIRED "A prompt or prompt file is required"; return 1; }
266
+ TIMEOUT=${TIMEOUT:-$([[ $PROFILE == review ]] && echo 600 || echo 1800)}
267
+ PROBE_TIMEOUT=${PROBE_TIMEOUT:-$([[ $PROFILE == review ]] && echo 30 || echo 60)}
268
+ positive_number "$TIMEOUT" && positive_number "$PROBE_TIMEOUT" || {
269
+ fail INVALID_TIMEOUT "Timeouts must be finite positive numbers"; return 1;
270
+ }
271
+ local stale_seconds stale_max_delete
272
+ stale_seconds=${CODEX_EXEC_STALE_SECONDS:-604800}
273
+ stale_max_delete=${CODEX_EXEC_STALE_MAX_DELETE:-8}
274
+ positive_number "$stale_seconds" && nonnegative_integer "$stale_max_delete" || {
275
+ fail INVALID_STALE_CONFIG "Stale cleanup limits must be a positive finite age and nonnegative integer count"
276
+ return 1
277
+ }
278
+ preflight "$CODEX_BIN" true || return 1
279
+ mkdir -p "$STATE_ROOT" && chmod 700 "$STATE_ROOT"
280
+ python3 "$PROC_HELPER" cleanup --state-root "$STATE_ROOT" \
281
+ --stale-seconds "$stale_seconds" --max-delete "$stale_max_delete" || {
282
+ fail STALE_CLEANUP_FAILED "Bounded stale-state cleanup failed"; return 1;
283
+ }
284
+ local state_dir run_id lease_token rc output
285
+ state_dir=$(mktemp -d "$STATE_ROOT/codex-exec.XXXXXXXX") || return 1
286
+ chmod 700 "$state_dir"
287
+ run_id=${state_dir##*.}
288
+ printf '%s\n' "$run_id" >"$state_dir/run-id"
289
+ printf '%s\n' "$PROFILE" >"$state_dir/profile"
290
+ printf '%s\n' "$SANDBOX" >"$state_dir/sandbox"
291
+ printf '1\n' >"$state_dir/next-round"
292
+ chmod 600 "$state_dir"/*
293
+ lease_token=$(acquire_lease "$state_dir") || {
294
+ fail ACTIVE_RUN "Run state is already owned by another lifecycle action"
295
+ return 1
296
+ }
297
+ output=$(launch_round "$state_dir" 1 "$lease_token")
298
+ rc=$?
299
+ release_or_fail "$state_dir" "$lease_token" "$run_id" "$output" || return 1
300
+ if [[ -n $output ]]; then
301
+ printf '%s\n' "$output" || return 1
302
+ else
303
+ supervisor_failed "$run_id" "$rc"
304
+ return 1
305
+ fi
306
+ return "$rc"
307
+ }
308
+
309
+ resume_run_locked() {
310
+ local state_dir=$1 lease_token=$2 stored_profile stored_sandbox thread_id round
311
+ [[ ! -f $state_dir/debug-retain ]] || {
312
+ fail RUN_FINALIZED "Run was finalized with debug retention"; return 1;
313
+ }
314
+ [[ -f $state_dir/profile && ! -L $state_dir/profile \
315
+ && -f $state_dir/sandbox && ! -L $state_dir/sandbox ]] || {
316
+ fail INVALID_STATE "Persisted profile or sandbox state is missing or unsafe"; return 1;
317
+ }
318
+ stored_profile=$(<"$state_dir/profile")
319
+ stored_sandbox=$(<"$state_dir/sandbox")
320
+ [[ $stored_profile == review || $stored_profile == build ]] || {
321
+ fail INVALID_STATE "Persisted profile is not in the exact allowlist"; return 1;
322
+ }
323
+ [[ $stored_sandbox == read-only || $stored_sandbox == workspace-write ]] || {
324
+ fail INVALID_STATE "Persisted sandbox is not in the exact allowlist"; return 1;
325
+ }
326
+ PROFILE=$stored_profile
327
+ if [[ -n $MODE && $MODE != "$stored_sandbox" ]]; then
328
+ fail MODE_MISMATCH "Resume mode differs from persisted mode"
329
+ return 1
330
+ fi
331
+ SANDBOX=$stored_sandbox
332
+ [[ -f $state_dir/thread-id ]] || { fail NO_THREAD "Run has no resumable thread" NO-THREAD; return 1; }
333
+ thread_id=$(<"$state_dir/thread-id")
334
+ round=$(<"$state_dir/next-round")
335
+ TIMEOUT=${TIMEOUT:-$([[ $PROFILE == review ]] && echo 600 || echo 1800)}
336
+ PROBE_TIMEOUT=${PROBE_TIMEOUT:-$([[ $PROFILE == review ]] && echo 30 || echo 60)}
337
+ positive_number "$TIMEOUT" && positive_number "$PROBE_TIMEOUT" || {
338
+ fail INVALID_TIMEOUT "Timeouts must be finite positive numbers"; return 1;
339
+ }
340
+ [[ ! -f $state_dir/runtime.json ]] || {
341
+ fail ACTIVE_RUN "Run has an unreconciled active runtime"; return 1;
342
+ }
343
+ preflight "$CODEX_BIN" true || return 1
344
+ launch_round "$state_dir" "$round" "$lease_token" "$thread_id"
345
+ }
346
+
347
+ resume_run() {
348
+ parse_options "$@" || return 1
349
+ [[ -n $RUN_ID ]] || { fail RUN_ID_REQUIRED "Resume requires --run-id"; return 1; }
350
+ local state_dir lease_token rc output
351
+ state_dir=$(find_state "$RUN_ID") || { fail RUN_NOT_FOUND "Run state does not exist"; return 1; }
352
+ lease_token=$(acquire_lease "$state_dir") || {
353
+ if [[ -d $state_dir ]]; then
354
+ fail ACTIVE_RUN "Run state is already owned by another lifecycle action"
355
+ else
356
+ fail RUN_NOT_FOUND "Run state does not exist"
357
+ fi
358
+ return 1
359
+ }
360
+ output=$(resume_run_locked "$state_dir" "$lease_token")
361
+ rc=$?
362
+ release_or_fail "$state_dir" "$lease_token" "$RUN_ID" "$output" || return 1
363
+ if [[ -z $output ]]; then
364
+ supervisor_failed "$RUN_ID" "$rc"
365
+ return 1
366
+ fi
367
+ printf '%s\n' "$output" || return 1
368
+ return "$rc"
369
+ }
370
+
371
+ finish_run() {
372
+ local action=$1; shift
373
+ parse_options "$@" || return 1
374
+ [[ -n $RUN_ID ]] || { fail RUN_ID_REQUIRED "$action requires --run-id"; return 1; }
375
+ local state_dir lease_token
376
+ state_dir=$(find_state "$RUN_ID") || { fail RUN_NOT_FOUND "Run state does not exist"; return 1; }
377
+ if [[ $action == abort ]]; then
378
+ lease_token=$(python3 "$PROC_HELPER" abort-claim --state-dir "$state_dir") || {
379
+ fail ABORT_FAILED "Run ownership could not be claimed for cleanup"; return 1;
380
+ }
381
+ else
382
+ lease_token=$(acquire_lease "$state_dir") || {
383
+ fail ACTIVE_RUN "Use abort for an active run"
384
+ return 1
385
+ }
386
+ if [[ -f $state_dir/runtime.json ]]; then
387
+ release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
388
+ fail ACTIVE_RUN "Use abort for an active run"
389
+ return 1
390
+ fi
391
+ fi
392
+ if [[ $DEBUG_RETAIN == false ]]; then
393
+ if ! rm -rf -- "$state_dir"; then
394
+ fail CLEANUP_FAILED "Run state could not be removed"
395
+ return 1
396
+ fi
397
+ else
398
+ if ! : >"$state_dir/debug-retain"; then
399
+ release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
400
+ fail DEBUG_RETAIN_FAILED "Debug-retain marker could not be created"
401
+ return 1
402
+ fi
403
+ release_or_fail "$state_dir" "$lease_token" "$RUN_ID" || return 1
404
+ fi
405
+ emit_json status=OK "action=$action" "runId=$RUN_ID" "retained=$DEBUG_RETAIN"
406
+ }
407
+
408
+ handle_failure() {
409
+ local result= fallback_run_id= result_set=false parsed kind resolved output cleanup_output cleanup_rc=0
410
+ while (($#)); do
411
+ case $1 in
412
+ --result|--run-id)
413
+ (($# >= 2)) || { fail INVALID_ARGUMENT "Missing value for $1"; return 1; }
414
+ if [[ $1 == --result ]]; then
415
+ result=$2
416
+ result_set=true
417
+ else
418
+ fallback_run_id=$2
419
+ fi
420
+ shift 2 ;;
421
+ *) fail INVALID_ARGUMENT "Unknown option: $1"; return 1 ;;
422
+ esac
423
+ done
424
+ [[ $result_set == true ]] || { fail RESULT_REQUIRED "handle-failure requires --result"; return 1; }
425
+
426
+ parsed=$(python3 - "$result" "$fallback_run_id" <<'PY'
427
+ import json
428
+ import sys
429
+
430
+ raw, fallback = sys.argv[1:]
431
+ known_statuses = {
432
+ "OK", "AUTH", "CANCELLED", "HUNG", "TIMEOUT", "SIGNALLED",
433
+ "EXEC_FAILED", "MALFORMED-JSON", "NO-THREAD", "NO-VERDICT",
434
+ }
435
+ try:
436
+ result = json.loads(raw)
437
+ if not isinstance(result, dict):
438
+ raise ValueError
439
+ status = result.get("status")
440
+ if not isinstance(status, str) or status not in known_statuses:
441
+ raise ValueError
442
+ except (json.JSONDecodeError, ValueError):
443
+ parsed = {
444
+ "kind": "malformed",
445
+ "resolved": fallback,
446
+ "output": {
447
+ "status": "MALFORMED_RESULT",
448
+ "error": "MALFORMED_RESULT",
449
+ "message": "Failure result was empty or malformed",
450
+ },
451
+ }
452
+ else:
453
+ cleanup_failed = result.get("cleanupStatus") == "FAILED"
454
+ if status == "OK" and not cleanup_failed:
455
+ parsed = {
456
+ "kind": "ok",
457
+ "resolved": "",
458
+ "output": {
459
+ "status": "EXEC_FAILED",
460
+ "error": "RESULT_NOT_FAILED",
461
+ "message": "handle-failure requires a non-OK result",
462
+ },
463
+ }
464
+ else:
465
+ malformed_fields = any(
466
+ key in result and not isinstance(result[key], str)
467
+ for key in (
468
+ "error", "message", "cleanupStatus", "cleanupError", "cleanupMessage",
469
+ )
470
+ )
471
+ result_run_id = result.get("runId")
472
+ malformed_run_id = (
473
+ "runId" in result
474
+ and (
475
+ not isinstance(result_run_id, str)
476
+ or not result_run_id
477
+ or not result_run_id.isascii()
478
+ or not result_run_id.isalnum()
479
+ )
480
+ )
481
+ if malformed_fields or malformed_run_id:
482
+ parsed = {
483
+ "kind": "malformed",
484
+ "resolved": fallback,
485
+ "output": {
486
+ "status": "MALFORMED_RESULT",
487
+ "error": "MALFORMED_RESULT",
488
+ "message": "Failure result was empty or malformed",
489
+ },
490
+ }
491
+ else:
492
+ output = result
493
+ if result_run_id:
494
+ resolved = result_run_id
495
+ else:
496
+ resolved = fallback
497
+ parsed = {"kind": "failure", "resolved": resolved, "output": output}
498
+ print(json.dumps(parsed, separators=(",", ":"), sort_keys=True))
499
+ PY
500
+ ) || {
501
+ fail MALFORMED_RESULT "Failure result was empty or malformed" MALFORMED_RESULT
502
+ return 1
503
+ }
504
+ kind=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["kind"])' "$parsed")
505
+ resolved=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["resolved"])' "$parsed")
506
+ output=$(python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["output"], separators=(",", ":"), sort_keys=True))' "$parsed")
507
+
508
+ if [[ $kind != ok && -n $resolved ]]; then
509
+ cleanup_output=$(finish_run abort --run-id "$resolved") || cleanup_rc=$?
510
+ if ((cleanup_rc)); then
511
+ output=$(python3 - "$output" "$cleanup_output" <<'PY'
512
+ import json
513
+ import sys
514
+
515
+ original = json.loads(sys.argv[1])
516
+ try:
517
+ cleanup = json.loads(sys.argv[2])
518
+ except json.JSONDecodeError:
519
+ cleanup = {}
520
+ if original.get("cleanupStatus") == "FAILED":
521
+ original["recoveryCleanupStatus"] = "FAILED"
522
+ original["recoveryCleanupError"] = cleanup.get("error", "CLEANUP_FAILED")
523
+ original["recoveryCleanupMessage"] = cleanup.get("message", "Run recovery cleanup failed")
524
+ else:
525
+ original["cleanupStatus"] = "FAILED"
526
+ original["cleanupError"] = cleanup.get("error", "CLEANUP_FAILED")
527
+ original["cleanupMessage"] = cleanup.get("message", "Run cleanup failed")
528
+ print(json.dumps(original, separators=(",", ":"), sort_keys=True))
529
+ PY
530
+ )
531
+ fi
532
+ fi
533
+ printf '%s\n' "$output"
534
+ return 1
535
+ }
536
+
537
+ dispatch_run_id_action() {
538
+ local action=$1; shift
539
+ if (($#)) && [[ $1 != --* ]]; then
540
+ local positional_run_id=$1; shift
541
+ if [[ $action == resume ]]; then
542
+ resume_run --run-id "$positional_run_id" "$@"
543
+ else
544
+ finish_run "$action" --run-id "$positional_run_id" "$@"
545
+ fi
546
+ elif [[ $action == resume ]]; then
547
+ resume_run "$@"
548
+ else
549
+ finish_run "$action" "$@"
550
+ fi
551
+ }
552
+
553
+ main() {
554
+ local action=${1:-}
555
+ [[ -n $action ]] || { fail ACTION_REQUIRED "Expected preflight, new, resume, finalize, abort, or handle-failure"; return 1; }
556
+ shift
557
+ case $action in
558
+ preflight) parse_options "$@" && preflight "$CODEX_BIN" ;;
559
+ new) new_run "$@" ;;
560
+ resume|finalize|abort) dispatch_run_id_action "$action" "$@" ;;
561
+ handle-failure) handle_failure "$@" ;;
562
+ *) fail INVALID_ACTION "Unknown action: $action" ;;
563
+ esac
564
+ }
565
+
566
+ main "$@"