#!/usr/bin/env bash # tests/fixtures/phase-lib/lib.sh -- a stand-in for the phased-execution skill's lib.sh. # # It is a CONTRACT fixture, not a reimplementation: the function names, the child call # shape (`pi "${PI_ARGS[@]}" --session-dir ... --mode json "$prompt"` piped to a progress # renderer), the gate call (`run_validation `) and the host-side commit + push # at a phase's commit point are what bin/ai-sandbox-autophase is written against, and # what breaks if that library changes shape. Keeping it here means the unit tests run # with no skill installed and no containers; tests/unit/10_autophase.sh also runs the # real library once, when it is installed. # # Sourced, never executed. PHASE_TODO=".agents/phases/todo" PHASE_DONE=".agents/phases/complete" PHASE_REPORTS=".agents/reports" PHASE_SESSIONS=".agents/phase-sessions" MAX_FIX_ATTEMPTS="${MAX_FIX_ATTEMPTS:-3}" FIXTURE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ts() { date '+%Y-%m-%d %H:%M:%S'; } warn() { echo "[$(ts)] ⚠ $*" >&2; } die() { echo "[$(ts)] ✗ ERROR: $*" >&2; exit 1; } find_root() { local d; d="$(pwd)" while :; do [[ -d "$d/.agents/phases/todo" ]] && { printf '%s\n' "$d"; return 0; } [[ "$d" == "/" ]] && return 1 d="$(dirname "$d")" done } # A phase entry is anything in todo/ whose name starts with a digit: a phase directory, or # a single .md file. Globs rather than ls|grep, so a name cannot be mis-split. phase_entries() { local e { ( cd "$PHASE_TODO" 2>/dev/null || exit 0 for e in [0-9]*; do [[ -e "$e" ]] && printf '%s\n' "$e"; done ) } | sort } phase_next_unit() { local p="$1" f t if [[ -d "$PHASE_TODO/$p" ]]; then for f in "$PHASE_TODO/$p"/[0-9]*.md; do [[ -f "$f" ]] || continue t="${f##*/}"; [[ "$t" == "00_phase.md" ]] && continue printf '%s/%s\n' "$p" "$t"; return 0 done [[ -f "$PHASE_TODO/$p/00_phase.md" ]] && { printf '%s/00_phase.md\n' "$p"; return 0; } elif [[ -f "$PHASE_TODO/$p" ]]; then printf '%s\n' "$p" fi return 0 } next_unit() { local p u for p in $(phase_entries); do u="$(phase_next_unit "$p")" [[ -n "$u" ]] && { printf '%s\n' "$u"; return 0; } done return 0 } count_units() { local p n=0 f for p in $(phase_entries); do if [[ -d "$PHASE_TODO/$p" ]]; then for f in "$PHASE_TODO/$p"/[0-9]*.md; do [[ -f "$f" ]] && n=$((n + 1)); done elif [[ -f "$PHASE_TODO/$p" ]]; then n=$((n + 1)) fi done printf '%s\n' "$n" } unit_phase() { local u="$1"; [[ "$u" == */* ]] && printf '%s\n' "${u%%/*}" || printf '%s\n' "${u%.md}"; } unit_base() { local u="${1%.md}"; printf '%s\n' "${u//\//__}"; } # 01_api/02_x.md -> 01_api__02_x is_phase_end() { local u="$1"; if [[ "$u" == */* ]]; then [[ "${u##*/}" == "00_phase.md" ]]; else return 0; fi; } unit_report_dir() { local u="$1" p if [[ "$u" == */* ]]; then p="${u%%/*}"; else p="."; fi printf '%s/%s\n' "$PHASE_REPORTS" "$p" } unit_report() { # printf '%s/%s.a%s.%s\n' "$(unit_report_dir "$1")" "$(unit_base "$1")" "$2" "$3" } move_unit() { local u="$1" if [[ "$u" == */* ]]; then mkdir -p "$PHASE_DONE/${u%%/*}" mv -f "$PHASE_TODO/$u" "$PHASE_DONE/${u%%/*}/${u##*/}" rmdir "$PHASE_TODO/${u%%/*}" 2>/dev/null || true else mkdir -p "$PHASE_DONE" mv -f "$PHASE_TODO/$u" "$PHASE_DONE/$u" fi } sweep_finished_phases() { return 0; } build_pi_args() { PI_ARGS=(--no-approve); } first_prompt() { printf 'Execute the unit %s. Reply with a report.\n' "$1"; } fix_prompt() { printf 'Fix the failures: %s\n' "${1:-}"; } run_validation() { bash .agents/validate.sh >"$1" 2>&1; } # The host-side commit + push, at the phase's commit point: same shape as the real # library's commit_phase/push_phase (commit, then push when PHASE_PUSH=1). commit_phase() { local phase="$1" git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 git add -A >/dev/null 2>&1 || return 1 if ! git diff --cached --quiet; then git commit --no-gpg-sign -m "phase: $phase" >/dev/null 2>&1 || return 1 fi # the real library hands the commit to the remote through push_phase, so this fixture # does too -- which is also the function the driver shadows to keep every push (and # every refusal to push) in one place. push_phase "$phase" || return 1 return 0 } # Mirrors the library's own entry point; the driver replaces it. push_phase() { git remote >/dev/null 2>&1 || return 0 git push >/dev/null 2>&1 || return 1 return 0 } notify_task() { return 0; } execute_unit() { local unit="$1" errors="" rc=0 mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" "$(unit_report_dir "$unit")" echo "[$(ts)] ━━ $unit — attempt 1/$MAX_FIX_ATTEMPTS ━━" # the real library prints this; tests read it build_pi_args local prompt out errf vrc prompt="$(first_prompt "$unit")" out="$(unit_report "$unit" 1 md)" errf="$(unit_report "$unit" 1 err)" pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$(unit_base "$unit")" \ --mode json "$prompt" 2>"$errf" | bash "$FIXTURE_LIB_DIR/progress.sh" "$out" rc="${PIPESTATUS[0]}" (( rc == 0 )) || errors+="child exited $rc"$'\n' run_validation "$(unit_report "$unit" 1 validate)"; vrc=$? (( vrc == 0 )) || errors+=".agents/validate.sh failed"$'\n' [[ -n "$errors" ]] && { printf '%s' "$errors" >&2; return 1; } move_unit "$unit" if is_phase_end "$unit"; then commit_phase "$unit" || return 1 fi return 0 }