#!/usr/bin/env bash # # VEZA — Script d'audit automatique overnight # Lance Claude Code sur chaque prompt d'audit, avec reprise sur erreur. # # Usage: # ./run-audit.sh # Lance depuis le début (ou reprend) # ./run-audit.sh --reset # Repart de zéro # ./run-audit.sh --from 15 # Force le démarrage au prompt 15 # ./run-audit.sh --only 38 # N'exécute qu'un seul prompt # ./run-audit.sh --dry-run # Affiche ce qui serait fait sans exécuter # ./run-audit.sh --status # Affiche l'état actuel # ./run-audit.sh --model sonnet # Utilise Sonnet au lieu d'Opus (moins cher) # # Coût estimé (Opus): ~$150–400 pour les 58 prompts # Coût estimé (Sonnet): ~$30–80 pour les 58 prompts # Durée estimée: 4–8 heures selon le modèle et la charge API # set -euo pipefail # ───────────────────────────────────────────── # Configuration # ───────────────────────────────────────────── REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" PROMPTS_DIR="$REPO_ROOT/prompts" STATE_DIR="$REPO_ROOT/.audit-state" STATE_FILE="$STATE_DIR/progress.json" LOG_DIR="$STATE_DIR/logs" BRANCH_NAME="audit/full-page-audit-$(date +%Y%m%d)" # Claude CLI settings MODEL="${VEZA_AUDIT_MODEL:-opus}" MAX_BUDGET_PER_PROMPT="${VEZA_AUDIT_BUDGET_PER_PROMPT:-8}" # USD par prompt MAX_RETRIES=3 RETRY_BACKOFF_BASE=60 # secondes, doublé à chaque retry COOLDOWN_BETWEEN_PROMPTS=10 # secondes entre chaque prompt RATE_LIMIT_PAUSE=300 # 5 min de pause si rate limit détecté # Couleurs RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # ───────────────────────────────────────────── # Fonctions utilitaires # ───────────────────────────────────────────── log() { echo -e "${CYAN}[$(date '+%H:%M:%S')]${NC} $*"; } info() { echo -e "${GREEN}[$(date '+%H:%M:%S')] ✓${NC} $*"; } warn() { echo -e "${YELLOW}[$(date '+%H:%M:%S')] ⚠${NC} $*"; } error() { echo -e "${RED}[$(date '+%H:%M:%S')] ✗${NC} $*"; } die() { error "$*"; exit 1; } # ───────────────────────────────────────────── # State management (fichier JSON simple via jq) # ───────────────────────────────────────────── ensure_deps() { command -v claude >/dev/null 2>&1 || die "claude CLI non trouvé. Installe-le d'abord." command -v jq >/dev/null 2>&1 || die "jq non trouvé. Installe: sudo dnf install jq" command -v git >/dev/null 2>&1 || die "git non trouvé." } init_state() { mkdir -p "$STATE_DIR" "$LOG_DIR" if [[ ! -f "$STATE_FILE" ]]; then cat > "$STATE_FILE" <<'ENDJSON' { "started_at": null, "branch": null, "prompts": {}, "current": null, "total_cost_estimate_usd": 0, "total_prompts": 0, "completed": 0, "failed": 0, "skipped": 0 } ENDJSON log "State file initialisé: $STATE_FILE" fi } state_get() { jq -r "$1" "$STATE_FILE" } state_set() { local tmp tmp=$(mktemp) jq "$1" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE" } get_prompt_status() { local num="$1" jq -r ".prompts[\"$num\"].status // \"pending\"" "$STATE_FILE" } set_prompt_status() { local num="$1" status="$2" detail="${3:-}" state_set ".prompts[\"$num\"] = { \"status\": \"$status\", \"updated_at\": \"$(date -Iseconds)\", \"detail\": \"$detail\" }" # Update counters local completed failed completed=$(jq '[.prompts[] | select(.status == "done")] | length' "$STATE_FILE") failed=$(jq '[.prompts[] | select(.status == "failed")] | length' "$STATE_FILE") state_set ".completed = $completed | .failed = $failed" } # ───────────────────────────────────────────── # Git helpers # ───────────────────────────────────────────── setup_branch() { cd "$REPO_ROOT" local current_branch current_branch=$(git branch --show-current) local existing_branch existing_branch=$(state_get '.branch // empty') if [[ -n "$existing_branch" ]] && git rev-parse --verify "$existing_branch" >/dev/null 2>&1; then # Reprendre sur la branche existante if [[ "$current_branch" != "$existing_branch" ]]; then log "Reprise sur branche existante: $existing_branch" git checkout "$existing_branch" fi BRANCH_NAME="$existing_branch" else # Créer nouvelle branche depuis l'état actuel if git rev-parse --verify "$BRANCH_NAME" >/dev/null 2>&1; then BRANCH_NAME="${BRANCH_NAME}-$(date +%H%M%S)" fi log "Création branche: $BRANCH_NAME" git checkout -b "$BRANCH_NAME" state_set ".branch = \"$BRANCH_NAME\"" fi state_set ".started_at = \"$(date -Iseconds)\"" } commit_prompt_changes() { local num="$1" name="$2" path="$3" cd "$REPO_ROOT" # Vérifier s'il y a des changements if git diff --quiet && git diff --cached --quiet && [[ -z "$(git ls-files --others --exclude-standard)" ]]; then warn "Prompt $num ($path): aucun changement à committer" return 0 fi # Stage tous les changements (sauf les fichiers d'état de l'audit) git add -A -- ':!.audit-state' # Vérifier à nouveau après staging if git diff --cached --quiet; then warn "Prompt $num ($path): rien dans le staging" return 0 fi git commit -m "$(cat < "$log_file" 2>&1 \ || exit_code=$? if [[ $exit_code -eq 0 ]]; then success=true break fi # Analyser l'erreur local last_lines last_lines=$(tail -20 "$log_file" 2>/dev/null || echo "") if echo "$last_lines" | grep -qi "rate.limit\|429\|too many requests\|overloaded"; then warn "Rate limit détecté. Pause de ${RATE_LIMIT_PAUSE}s..." sleep "$RATE_LIMIT_PAUSE" elif echo "$last_lines" | grep -qi "context.window\|token.limit\|max.*token\|session.*limit"; then warn "Limite de session/contexte atteinte. Prompt trop lourd, on continue." break elif echo "$last_lines" | grep -qi "network\|connection\|ECONNRESET\|ETIMEDOUT\|fetch failed"; then local backoff=$((RETRY_BACKOFF_BASE * (2 ** (attempt - 1)))) warn "Erreur réseau. Backoff ${backoff}s..." sleep "$backoff" elif [[ $exit_code -eq 124 ]]; then warn "Timeout (30min). Le prompt a pris trop de temps." break else local backoff=$((RETRY_BACKOFF_BASE * (2 ** (attempt - 1)))) warn "Erreur inconnue (code $exit_code). Backoff ${backoff}s..." sleep "$backoff" fi done if $success; then # Commit les changements commit_prompt_changes "$num" "$route_name" "$(head -1 "$prompt_file" | grep -oP '/[a-z/:-]+')" || true set_prompt_status "$num" "done" info "Prompt $num terminé avec succès" else set_prompt_status "$num" "failed" "after $attempt attempts, exit code $exit_code" error "Prompt $num échoué après $attempt tentatives" # On continue quand même avec le suivant fi # Cooldown entre les prompts if (( COOLDOWN_BETWEEN_PROMPTS > 0 )); then log "Cooldown ${COOLDOWN_BETWEEN_PROMPTS}s..." sleep "$COOLDOWN_BETWEEN_PROMPTS" fi } # ───────────────────────────────────────────── # Collecte des prompts # ───────────────────────────────────────────── get_prompt_files() { find "$PROMPTS_DIR" -name '*.md' -not -name 'ALL-PROMPTS.md' | sort } # ───────────────────────────────────────────── # Commandes principales # ───────────────────────────────────────────── cmd_status() { if [[ ! -f "$STATE_FILE" ]]; then log "Aucun audit en cours." return fi echo "" echo -e "${CYAN}═══ VEZA Audit Status ═══${NC}" echo "" echo -e " Branche: $(state_get '.branch // "non démarrée"')" echo -e " Démarré: $(state_get '.started_at // "—"')" echo -e " Complétés: ${GREEN}$(state_get '.completed')${NC}" echo -e " Échoués: ${RED}$(state_get '.failed')${NC}" echo -e " En cours: $(state_get '.current // "—"')" echo "" # Détail par prompt local total=0 done=0 failed=0 pending=0 for f in $(get_prompt_files); do total=$((total + 1)) local num num=$((10#$(basename "$f" | grep -oP '^\d+'))) local st st=$(get_prompt_status "$num") local icon="⏳" case "$st" in done) icon="✅"; done=$((done + 1)) ;; failed) icon="❌"; failed=$((failed + 1)) ;; running) icon="🔄" ;; *) pending=$((pending + 1)) ;; esac printf " %s %02d. %s\n" "$icon" "$num" "$(basename "$f" .md | sed 's/^[0-9]*-//')" done echo "" echo -e " Total: $total | ✅ $done | ❌ $failed | ⏳ $pending" echo "" } cmd_reset() { warn "Reset de l'état d'audit..." rm -rf "$STATE_DIR" info "État supprimé. Relance ./run-audit.sh pour recommencer." } cmd_run() { local start_from="${1:-0}" local only="${2:-0}" ensure_deps init_state local prompt_files mapfile -t prompt_files < <(get_prompt_files) local total=${#prompt_files[@]} state_set ".total_prompts = $total" log "═══════════════════════════════════════════════════════" log " VEZA — Audit automatique de $total pages" log " Modèle: $MODEL" log " Budget max par prompt: \$${MAX_BUDGET_PER_PROMPT}" log " Budget max total estimé: \$$(echo "$total * $MAX_BUDGET_PER_PROMPT" | bc)" log "═══════════════════════════════════════════════════════" setup_branch for prompt_file in "${prompt_files[@]}"; do local num num=$((10#$(basename "$prompt_file" | grep -oP '^\d+'))) # --from: sauter les prompts avant if (( start_from > 0 && num < start_from )); then continue fi # --only: n'exécuter qu'un seul if (( only > 0 && num != only )); then continue fi run_single_prompt "$prompt_file" done state_set ".current = null" # Résumé final echo "" log "═══════════════════════════════════════════════════════" log " AUDIT TERMINÉ" log "═══════════════════════════════════════════════════════" cmd_status local failed_count failed_count=$(state_get '.failed') if (( failed_count > 0 )); then warn "$failed_count prompts ont échoué. Relance pour retenter uniquement les échecs." warn "Les logs sont dans: $LOG_DIR/" fi info "Branche: $BRANCH_NAME" info "Commits: $(git log --oneline "$BRANCH_NAME" --not main | wc -l) commits" info "Logs: $LOG_DIR/" } cmd_dry_run() { init_state local prompt_files mapfile -t prompt_files < <(get_prompt_files) echo "" log "═══ DRY RUN — Voici ce qui serait exécuté ═══" echo "" for prompt_file in "${prompt_files[@]}"; do local num num=$((10#$(basename "$prompt_file" | grep -oP '^\d+'))) local st st=$(get_prompt_status "$num") local action="EXÉCUTER" [[ "$st" == "done" ]] && action="SKIP (déjà fait)" printf " %02d. %-40s → %s\n" "$num" "$(basename "$prompt_file")" "$action" done local pending pending=$(jq '[.prompts[] | select(.status == "done")] | length' "$STATE_FILE" 2>/dev/null || echo 0) local total=${#prompt_files[@]} local to_run=$((total - pending)) echo "" echo " À exécuter: $to_run / $total prompts" echo " Modèle: $MODEL" echo " Budget max: \$$(echo "$to_run * $MAX_BUDGET_PER_PROMPT" | bc)" echo " Durée estimée: $(echo "$to_run * 8" | bc) min (≈$(echo "$to_run * 8 / 60" | bc)h)" echo "" } # ───────────────────────────────────────────── # CLI parsing # ───────────────────────────────────────────── main() { cd "$REPO_ROOT" local start_from=0 local only=0 local dry_run=false while [[ $# -gt 0 ]]; do case "$1" in --reset) cmd_reset exit 0 ;; --status) cmd_status exit 0 ;; --dry-run) dry_run=true shift ;; --from) start_from="$2" shift 2 ;; --only) only="$2" shift 2 ;; --model) MODEL="$2" shift 2 ;; --budget) MAX_BUDGET_PER_PROMPT="$2" shift 2 ;; -h|--help) echo "Usage: $0 [options]" echo "" echo "Options:" echo " --reset Supprime l'état et repart de zéro" echo " --status Affiche la progression actuelle" echo " --dry-run Montre ce qui serait fait sans exécuter" echo " --from N Commence au prompt N" echo " --only N N'exécute que le prompt N" echo " --model MODEL Modèle Claude (opus|sonnet|haiku) [défaut: opus]" echo " --budget N Budget max USD par prompt [défaut: 8]" echo "" echo "Variables d'environnement:" echo " VEZA_AUDIT_MODEL Modèle par défaut" echo " VEZA_AUDIT_BUDGET_PER_PROMPT Budget par prompt" exit 0 ;; *) die "Option inconnue: $1 (utilise --help)" ;; esac done if $dry_run; then cmd_dry_run else cmd_run "$start_from" "$only" fi } main "$@"