Files
boshu2__agentops/scripts/smoke-test-codex-skills.sh
T
Bo 922b7c908a fix(scripts): clean 3 more pre-existing shellcheck warnings (round 2, soc-ruvk) (#353)
## Summary

Round 2 of pre-existing shellcheck cleanup. PR #349 (soc-j026) cleaned 6
files; the full pre-push gate's broader shellcheck step caught 3 more
pre-existing warnings on canonical post-#350.

Closes: soc-ruvk
Discovered-from: soc-j026

## Fixes

| File | Warning | Fix |
|---|---|---|
| `rpi-stream.sh:177` | SC2154 'p referenced but not assigned' inside
trap body | disable directive (`p` is the loop variable in the
single-quoted trap, not an outer reference) |
| `smoke-test-codex-skills.sh:125,141` | SC2088 tilde-in-quotes |
disable directives (intentional literal pattern match of the documented
path) |
| `cleanup-global-agents.sh:22` | SC2034 `LEAKED_DIRS` unused | convert
dead array to comment (script uses `ALLOWED_DIRS` whitelist; LEAKED_DIRS
was orphaned documentation) |

## Bundled sync

Includes `cp CHANGELOG.md docs/CHANGELOG.md` — same pre-existing sync
drift from PR #351 that PR #352 also fixes. Bundling lets this PR's gate
pass independently of #352's merge order. If #352 lands first, this
becomes a trivial conflict-resolve.

## Validation

- `shellcheck -S warning` clean on all 3 named files.
- `scripts/ship.sh` fast gate: PASSED.
- This is the same coherent-arc shape as PR #349 — different file set,
identical disposition (atomic, mechanical, no behavioral change).

Bounded-context: BC0-foundations (scripts)
Evidence: `shellcheck -S warning
scripts/{rpi-stream,smoke-test-codex-skills,cleanup-global-agents}.sh`
(clean)

Co-authored-by: Codex <codex@example.invalid>
2026-05-19 13:44:32 +00:00

430 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# smoke-test-codex-skills.sh — DAG-based headless smoke test for Codex skills.
# Traverses skill dependency graph in topological order, spawns codex exec
# per skill, collects PASS/PARTIAL/FAIL verdicts.
#
# Usage:
# scripts/smoke-test-codex-skills.sh [OPTIONS]
#
# Options:
# --dry-run Print what would run without spawning Codex
# --chain N Run only chain N (1-4)
# --skill NAME Run only a single skill
# --timeout SECS Per-skill timeout (default: 90)
# --parallel N Max parallel Codex invocations (default: 4)
# --model MODEL Codex model (default: gpt-5.4)
# --static-only Run only static checks (no codex exec)
# --json Output results as JSON
# --verbose Show Codex output for each skill
# --help Show this help
#
# Exit codes:
# 0 = PASS (all skills PASS or PARTIAL)
# 1 = FAIL (any skill BLOCKED/FAIL)
# 2 = ERROR (script error)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILLS_CODEX="$REPO_ROOT/skills-codex"
RESULTS_DIR="$REPO_ROOT/.agents/smoke-test"
# Defaults
DRY_RUN=false
CHAIN_FILTER=""
SKILL_FILTER=""
TIMEOUT=90
PARALLEL=4
MODEL="gpt-5.4"
STATIC_ONLY=false
JSON_OUTPUT=false
VERBOSE=false
# --- Argument parsing ---
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--chain) CHAIN_FILTER="$2"; shift 2 ;;
--skill) SKILL_FILTER="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--parallel) PARALLEL="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
--static-only) STATIC_ONLY=true; shift ;;
--json) JSON_OUTPUT=true; shift ;;
--verbose) VERBOSE=true; shift ;;
--help)
awk 'NR==1{next} /^[^#]/{exit} {sub(/^# ?/,""); print}' "$0"
exit 0
;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
# --- Preflight ---
if [[ ! -d "$SKILLS_CODEX" ]]; then
echo "Error: skills-codex directory not found: $SKILLS_CODEX" >&2
exit 2
fi
if [[ "$STATIC_ONLY" == "false" && "$DRY_RUN" == "false" ]]; then
if ! command -v codex &>/dev/null; then
echo "Error: codex CLI not found. Install or use --static-only." >&2
exit 2
fi
fi
mkdir -p "$RESULTS_DIR"
# --- DAG Definition ---
# Extracted from skill body analysis (see .agents/handoff/2026-03-14-codex-api-alignment.md)
# Format: LAYER[n]="skill1 skill2 ..."
LAYER0="standards shared beads brainstorm inject forge retro ratchet provenance compile handoff recover quickstart goals flywheel openai-docs oss-docs product security security-suite release converter update using-agentops status heal-skill codex-team pr-research pr-plan pr-implement pr-validate pr-prep pr-retro grafana-platform-dashboard reverse-engineer-rpi push"
LAYER1="council research doc implement bug-hunt trace readme"
LAYER2="pre-mortem post-mortem vibe complexity"
LAYER3="swarm validation plan"
LAYER4="crank discovery"
LAYER5="rpi evolve"
# Chains for traversal (minimum covering paths)
CHAIN1="standards council pre-mortem plan research inject brainstorm discovery implement beads swarm vibe complexity bug-hunt crank post-mortem validation retro forge rpi ratchet"
CHAIN2="pr-research pr-plan pr-implement pr-validate pr-prep pr-retro"
CHAIN3="compile evolve flywheel provenance trace"
CHAIN4="doc readme handoff recover status quickstart goals product oss-docs release security security-suite heal-skill codex-team update converter using-agentops grafana-platform-dashboard reverse-engineer-rpi push openai-docs shared"
# --- Static Validation ---
static_check() {
local skill_name="$1"
local skill_md="$SKILLS_CODEX/$skill_name/SKILL.md"
local issues=()
if [[ ! -f "$skill_md" ]]; then
echo "MISSING"
return
fi
# Check 1: Frontmatter — only name + description
local fm
fm=$(awk 'NR==1 && /^---$/{in_fm=1; next} in_fm && /^---$/{exit} in_fm{print}' "$skill_md")
local bad_fields
bad_fields=$(echo "$fm" | grep -oE '^[a-z_-]+:' | sed 's/:$//' | grep -vE '^(name|description)$' || true)
if [[ -n "$bad_fields" ]]; then
issues+=("bad-frontmatter:$bad_fields")
fi
# Check 2: Claude primitives
local body
body=$(awk 'BEGIN{skip=0} NR==1 && /^---$/{skip=1; next} skip && /^---$/{skip=0; next} !skip{print}' "$skill_md")
local primitives='TaskCreate|TaskList|TaskUpdate|TaskGet|TaskStop|TeamCreate|TeamDelete|SendMessage|EnterPlanMode|ExitPlanMode|EnterWorktree|todo_write|update_plan'
if echo "$body" | grep -qE "\b($primitives)\b" 2>/dev/null; then
issues+=("claude-primitives")
fi
# Check 3: Claude paths
# shellcheck disable=SC2088 # intentional literal match of the documented path
if grep -q '~/\.claude/' "$skill_md" 2>/dev/null; then
issues+=("claude-paths")
fi
# Check 4: Skill() invocations
if grep -q 'Skill(skill=' "$skill_md" 2>/dev/null; then
issues+=("skill-tool-invocation")
fi
# Check 5: Reference files
local refs_dir="$SKILLS_CODEX/$skill_name/references"
if [[ -d "$refs_dir" ]]; then
while IFS= read -r ref_file; do
if grep -qE "\b($primitives)\b" "$ref_file" 2>/dev/null; then
issues+=("ref-primitives:$(basename "$ref_file")")
fi
# shellcheck disable=SC2088 # intentional literal match of the documented path
if grep -q '~/\.claude/' "$ref_file" 2>/dev/null; then
issues+=("ref-claude-paths:$(basename "$ref_file")")
fi
done < <(find "$refs_dir" -name '*.md' -type f 2>/dev/null)
fi
if [[ ${#issues[@]} -eq 0 ]]; then
echo "CLEAN"
else
echo "ISSUES:$(IFS=','; echo "${issues[*]}")"
fi
}
# --- Codex Smoke Test ---
codex_smoke() {
local skill_name="$1"
local result_file="$RESULTS_DIR/${skill_name}.json"
local prompt="Smoke test the '\$${skill_name}' skill. You are in read-only mode — do NOT try to execute the skill or write files.
Steps:
1. Find and read the SKILL.md for '${skill_name}' (check skills-codex/${skill_name}/SKILL.md)
2. Verify the skill loads (has valid frontmatter with name + description)
3. Check if the instructions reference tools/primitives that don't exist in Codex (e.g. TaskCreate, TeamCreate, SendMessage, EnterPlanMode — these are Claude-only)
4. Check if \$skill invocations reference skills that exist in skills-codex/
Rate the skill:
- PASS: loads correctly, all referenced tools/skills exist in Codex
- PARTIAL: loads but references some unavailable tools/features (list which ones)
- FAIL: won't load, has broken frontmatter, or references only non-existent primitives
IMPORTANT: Read-only sandbox and missing network access are NOT reasons to FAIL — those are test environment limits, not skill defects.
Output EXACTLY one JSON line at the end:
{\"skill\": \"${skill_name}\", \"verdict\": \"PASS|PARTIAL|FAIL\", \"reason\": \"brief explanation\"}"
local codex_output
if codex_output=$(timeout "$TIMEOUT" codex exec -s read-only -m "$MODEL" -C "$REPO_ROOT" "$prompt" 2>&1); then
# Extract JSON verdict from output
local json_line
json_line=$(echo "$codex_output" | tr -d '\n' | grep -oE '\{[^}]*"verdict"[^}]*\}' | tail -1 || true)
if [[ -n "$json_line" ]]; then
echo "$json_line" > "$result_file"
local verdict
verdict=$(echo "$json_line" | sed -n 's/.*"verdict"[[:space:]]*:[[:space:]]*"\([A-Z]*\)".*/\1/p' | tail -1)
verdict="${verdict:-UNKNOWN}"
if [[ "$VERBOSE" == "true" ]]; then
echo "$codex_output" >&2
fi
echo "$verdict"
else
echo "{\"skill\": \"$skill_name\", \"verdict\": \"FAIL\", \"reason\": \"No JSON verdict in output\"}" > "$result_file"
if [[ "$VERBOSE" == "true" ]]; then
echo "$codex_output" >&2
fi
echo "FAIL"
fi
else
local exit_code=$?
if [[ $exit_code -eq 124 ]]; then
echo "{\"skill\": \"$skill_name\", \"verdict\": \"FAIL\", \"reason\": \"Timeout after ${TIMEOUT}s\"}" > "$result_file"
echo "TIMEOUT"
else
echo "{\"skill\": \"$skill_name\", \"verdict\": \"FAIL\", \"reason\": \"Codex exit code $exit_code\"}" > "$result_file"
echo "ERROR"
fi
fi
}
# --- Build skill list based on filters ---
get_skills() {
local skills=""
if [[ -n "$SKILL_FILTER" ]]; then
echo "$SKILL_FILTER"
return
fi
if [[ -n "$CHAIN_FILTER" ]]; then
case "$CHAIN_FILTER" in
1) skills="$CHAIN1" ;;
2) skills="$CHAIN2" ;;
3) skills="$CHAIN3" ;;
4) skills="$CHAIN4" ;;
*) echo "Invalid chain: $CHAIN_FILTER (must be 1-4)" >&2; exit 2 ;;
esac
echo "$skills"
return
fi
# All skills in topological order (deduped across chains)
echo "$LAYER0 $LAYER1 $LAYER2 $LAYER3 $LAYER4 $LAYER5"
}
# --- Main ---
main() {
local skills
skills=$(get_skills)
local total=0
local pass=0
local partial=0
local fail=0
# shellcheck disable=SC2034
# reserved for future use
local blocked=0
declare -A static_results
declare -A smoke_results
echo "=== Codex Skill Smoke Test ==="
echo "Mode: $(if $STATIC_ONLY; then echo 'static-only'; elif $DRY_RUN; then echo 'dry-run'; else echo "live (model=$MODEL, timeout=${TIMEOUT}s, parallel=$PARALLEL)"; fi)"
echo "Skills: $(echo "$skills" | wc -w | tr -d ' ')"
echo ""
# Phase 1: Static validation (always runs)
echo "--- Phase 1: Static Validation ---"
for skill in $skills; do
if [[ -d "$SKILLS_CODEX/$skill" ]]; then
local result
result=$(static_check "$skill")
static_results["$skill"]="$result"
if [[ "$result" == "CLEAN" ]]; then
printf " %-30s %s\n" "$skill" "CLEAN"
else
printf " %-30s %s\n" "$skill" "$result"
fi
else
static_results["$skill"]="MISSING"
printf " %-30s %s\n" "$skill" "MISSING (no skills-codex/ dir)"
fi
total=$((total + 1))
done
echo ""
# Phase 2: Headless Codex smoke test (unless --static-only)
if [[ "$STATIC_ONLY" == "true" ]]; then
echo "--- Phase 2: Skipped (--static-only) ---"
echo ""
# Score from static results only
for skill in $skills; do
case "${static_results[$skill]}" in
CLEAN) pass=$((pass + 1)) ;;
MISSING) fail=$((fail + 1)) ;;
*) partial=$((partial + 1)) ;;
esac
done
elif [[ "$DRY_RUN" == "true" ]]; then
echo "--- Phase 2: Dry Run ---"
for skill in $skills; do
echo " Would run: codex exec -s read-only -m $MODEL -C $REPO_ROOT \"smoke test $skill\""
done
echo ""
# In dry-run, score from static only
for skill in $skills; do
case "${static_results[$skill]}" in
CLEAN) pass=$((pass + 1)) ;;
MISSING) fail=$((fail + 1)) ;;
*) partial=$((partial + 1)) ;;
esac
done
else
echo "--- Phase 2: Headless Codex Smoke Test ---"
# Parallel execution with job control
local running=0
local pids=()
local pid_skills=()
for skill in $skills; do
# Skip skills that failed static check badly
if [[ "${static_results[$skill]}" == "MISSING" ]]; then
smoke_results["$skill"]="SKIP"
printf " %-30s %s\n" "$skill" "SKIP (missing)"
fail=$((fail + 1))
continue
fi
# Throttle parallel jobs
while [[ $running -ge $PARALLEL ]]; do
# Wait for any child to finish
for i in "${!pids[@]}"; do
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
wait "${pids[$i]}" 2>/dev/null || true
local completed_skill="${pid_skills[$i]}"
local result_file="$RESULTS_DIR/${completed_skill}.verdict"
if [[ -f "$result_file" ]]; then
local verdict
verdict=$(cat "$result_file")
smoke_results["$completed_skill"]="$verdict"
printf " %-30s %s\n" "$completed_skill" "$verdict"
case "$verdict" in
PASS) pass=$((pass + 1)) ;;
PARTIAL) partial=$((partial + 1)) ;;
*) fail=$((fail + 1)) ;;
esac
else
smoke_results["$completed_skill"]="ERROR"
printf " %-30s %s\n" "$completed_skill" "ERROR (no verdict file)"
fail=$((fail + 1))
fi
unset 'pids[i]'
unset 'pid_skills[i]'
running=$((running - 1))
fi
done
# Reindex arrays
pids=("${pids[@]}")
pid_skills=("${pid_skills[@]}")
sleep 0.5
done
# Launch smoke test in background
(
verdict=$(codex_smoke "$skill")
echo "$verdict" > "$RESULTS_DIR/${skill}.verdict"
) &
pids+=($!)
pid_skills+=("$skill")
running=$((running + 1))
done
# Wait for remaining jobs
for i in "${!pids[@]}"; do
wait "${pids[$i]}" 2>/dev/null || true
local completed_skill="${pid_skills[$i]}"
local result_file="$RESULTS_DIR/${completed_skill}.verdict"
if [[ -f "$result_file" ]]; then
local verdict
verdict=$(cat "$result_file")
smoke_results["$completed_skill"]="$verdict"
printf " %-30s %s\n" "$completed_skill" "$verdict"
case "$verdict" in
PASS) pass=$((pass + 1)) ;;
PARTIAL) partial=$((partial + 1)) ;;
*) fail=$((fail + 1)) ;;
esac
else
smoke_results["$completed_skill"]="ERROR"
printf " %-30s %s\n" "$completed_skill" "ERROR (no verdict file)"
fail=$((fail + 1))
fi
done
echo ""
fi
# --- Summary ---
echo "=== Release Gate Verdict ==="
echo "Total: $total PASS: $pass PARTIAL: $partial FAIL: $fail"
echo ""
if [[ "$JSON_OUTPUT" == "true" ]]; then
echo "{"
echo " \"total\": $total,"
echo " \"pass\": $pass,"
echo " \"partial\": $partial,"
echo " \"fail\": $fail,"
echo " \"verdict\": \"$(if [[ $fail -eq 0 ]]; then echo "PASS"; else echo "FAIL"; fi)\","
echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
echo " \"model\": \"$MODEL\","
echo " \"static_only\": $STATIC_ONLY"
echo "}"
fi
if [[ $fail -gt 0 ]]; then
echo "VERDICT: FAIL ($fail skill(s) failed)"
echo ""
echo "Failed skills:"
for skill in $skills; do
if [[ "${static_results[$skill]:-}" == "MISSING" ]] || \
[[ "${smoke_results[$skill]:-}" == "FAIL" ]] || \
[[ "${smoke_results[$skill]:-}" == "TIMEOUT" ]] || \
[[ "${smoke_results[$skill]:-}" == "ERROR" ]]; then
echo " - $skill: static=${static_results[$skill]:-n/a} smoke=${smoke_results[$skill]:-n/a}"
fi
done
exit 1
else
echo "VERDICT: PASS (all skills operational)"
exit 0
fi
}
main