#!/usr/bin/env bash # lib.sh — shared logic for the phased-execution skill. # Sourced by run-task.sh, run-phase.sh, and auto-phase.sh. Not meant to be run directly. # # Phase state lives in files, not chat context: # .agents/PLAN.md master plan, LOCKED DECISIONS (binding) # .agents/phases/todo/NN_name/ pending phase: 00_phase.md (overview) + NN_task.md task files # .agents/phases/todo/NN_name.md legacy single-file phase (still executable) # .agents/phases/complete/ finished phases — mirrors the todo/ layout # .agents/reports/ per-task executor reports, stderr, validation logs # .agents/phase-sessions/ child pi session files (resumable fixers) # # The unit of execution is the TASK: each task file runs in its own pi # subprocess (fresh context) with bounded fixer retries, and # .agents/validate.sh runs after EVERY task. A unit only moves to complete/ # after the child exits 0, the child's stream ends with a clean final # report, and validation passes. When all of a phase's tasks are done, # 00_phase.md runs as the phase's final pass (any remaining inline work + # completion criteria + phase-level verification); moving it completes the # phase and is the commit point. Children never commit — by default # (PHASE_COMMIT=1) the harness makes ONE atomic commit per completed phase # (code changes + the file move + the executor reports), scoped so the # owner's pre-existing worktree changes are left alone, and pushes it when # the repo has a remote (PHASE_PUSH=1; see commit_phase / push_phase). SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/executor-prompt.md" TASK_EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/task-executor-prompt.md" PHASE_FINAL_PROMPT_FILE="$SKILL_DIR/assets/phase-final-prompt.md" 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}" warn() { echo "⚠ $*" >&2; } die() { echo "✗ ERROR: $*" >&2; exit 1; } # --- project root ------------------------------------------------------------- # Walk up from $PWD to the nearest directory containing .agents/phases/todo. find_root() { local d d="$(pwd)" while :; do if [[ -d "$d/.agents/phases/todo" ]]; then printf '%s\n' "$d"; return 0; fi [[ "$d" == "/" ]] && return 1 d="$(dirname "$d")" done } # --- unit selection ------------------------------------------------------------- # A unit is the smallest schedulable piece of work, referenced relative to # .agents/phases/todo/: # directory phase → each task file "NN_name/NN_task.md" (sort order), then # the phase overview "NN_name/00_phase.md" as the final pass # legacy flat → the phase file itself, "NN_name.md" # Pending phase entries in todo/ (phase directories or legacy .md files), in execution order. phase_entries() { ( cd "$PHASE_TODO" 2>/dev/null && ls -1 | grep -E '^[0-9]' | sort ) || true } # First pending unit of one phase entry, or empty when the phase is done. phase_next_unit() { local p="$1" t if [[ -d "$PHASE_TODO/$p" ]]; then t="$(ls -1 "$PHASE_TODO/$p" 2>/dev/null | grep -E '^[0-9].*\.md$' | grep -vE '^00_phase\.md$' | sort | head -n1)" if [[ -n "$t" ]]; then printf '%s/%s\n' "$p" "$t"; return 0; fi if [[ -f "$PHASE_TODO/$p/00_phase.md" ]]; then printf '%s/00_phase.md\n' "$p"; return 0; fi elif [[ -f "$PHASE_TODO/$p" ]]; then printf '%s\n' "$p" fi return 0 } # Next pending unit in the whole pipeline, or empty when everything is done. next_unit() { local p u for p in $(phase_entries); do u="$(phase_next_unit "$p")" if [[ -n "$u" ]]; then printf '%s\n' "$u"; return 0; fi done return 0 } # Count of pending units across all phases (task files + 00_phase.md + legacy files). count_units() { local p n=0 for p in $(phase_entries); do if [[ -d "$PHASE_TODO/$p" ]]; then n=$(( n + $(ls -1 "$PHASE_TODO/$p" 2>/dev/null | grep -cE '\.md$') )) elif [[ -f "$PHASE_TODO/$p" ]]; then n=$(( n + 1 )) fi done printf '%s\n' "$n" } # Name used in reports/sessions: "NN_name__NN_task" (dir phases) or "NN_name" (flat). unit_base() { local u="${1%.md}" printf '%s\n' "${u//\//__}" } # Phase name for a unit (both forms). unit_phase() { local u="${1%.md}" printf '%s\n' "${u%%/*}" } # Reports directory for a unit: per-phase subdir for dir phases, flat otherwise. unit_report_dir() { local u="$1" if [[ "$u" == */* ]]; then printf '%s/%s\n' "$PHASE_REPORTS" "$(unit_phase "$u")" else printf '%s\n' "$PHASE_REPORTS" fi } # Report/log path for a unit attempt: /.a.. unit_report() { local u="$1" attempt="$2" ext="$3" printf '%s/%s.a%s.%s\n' "$(unit_report_dir "$u")" "$(unit_base "$u")" "$attempt" "$ext" } # Phase-end unit (the commit point): 00_phase.md, or the whole file for legacy. is_phase_end() { local u="$1" if [[ "$u" == */* ]]; then [[ "${u##*/}" == "00_phase.md" ]]; else return 0; fi } # Move a completed unit from todo/ to complete/ (same relative path). move_unit() { local u="$1" p t if [[ "$u" == */* ]]; then p="${u%%/*}"; t="${u##*/}" mkdir -p "$PHASE_DONE/$p" mv -f "$PHASE_TODO/$u" "$PHASE_DONE/$p/$t" if [[ -z "$(ls -A "$PHASE_TODO/$p" 2>/dev/null)" ]]; then rmdir "$PHASE_TODO/$p"; fi else mkdir -p "$PHASE_DONE" mv -f "$PHASE_TODO/$u" "$PHASE_DONE/$u" fi } # --- child pi arguments ------------------------------------------------------- build_pi_args() { PI_ARGS=() [[ -n "${PHASE_MODEL:-}" ]] && PI_ARGS+=(--model "$PHASE_MODEL") [[ -n "${PHASE_THINKING:-}" ]] && PI_ARGS+=(--thinking "$PHASE_THINKING") if [[ "${PI_TRUST:-0}" == "1" ]]; then PI_ARGS+=(--approve) # load project .pi/ settings, skills, extensions else PI_ARGS+=(--no-approve) # deterministic: global config + AGENTS.md only fi } # Most recently modified child session file (or empty). latest_child_session() { ls -t "$PHASE_SESSIONS"/*.jsonl 2>/dev/null | head -n1 || true } # Write the last assistant text message of a session file to a report file. # Recovery path: when the child's JSON stream loses its tail (e.g. the child # is signaled mid-flush), the session file still holds the final report. # Returns 1 when there is no recoverable assistant text. recover_report() { local report="$1" session="$2" [[ -f "$session" ]] || return 1 node -e ' const [report, file] = process.argv.slice(1); const { readFileSync, writeFileSync } = require("node:fs"); let last = ""; for (const line of readFileSync(file, "utf8").split("\n")) { const t = line.trim(); if (!t) continue; let e; try { e = JSON.parse(t); } catch { continue; } const m = e && e.type === "message" ? e.message : null; if (!m || m.role !== "assistant") continue; let text = ""; if (typeof m.content === "string") text = m.content; else if (Array.isArray(m.content)) text = m.content.filter((b) => b && b.type === "text").map((b) => b.text).join(""); if (text.trim()) last = text.trim(); } if (!last) process.exit(1); writeFileSync(report, last + "\n"); ' "$report" "$session" } # --- prompts ------------------------------------------------------------------ # First-attempt prompt for a unit. Directory tasks render the task executor # prompt; the phase overview renders the phase-final prompt; legacy flat # phase files render the original phase executor prompt. first_prompt() { local unit="$1" p t if [[ "$unit" == */* ]]; then p="${unit%%/*}"; t="${unit##*/}" if [[ "$t" == "00_phase.md" ]]; then [[ -f "$PHASE_FINAL_PROMPT_FILE" ]] || die "missing $PHASE_FINAL_PROMPT_FILE" sed "s|{{PHASE}}|$p|g" "$PHASE_FINAL_PROMPT_FILE" else [[ -f "$TASK_EXECUTOR_PROMPT_FILE" ]] || die "missing $TASK_EXECUTOR_PROMPT_FILE" sed "s|{{PHASE}}|$p|g; s|{{TASK}}|$t|g" "$TASK_EXECUTOR_PROMPT_FILE" fi else [[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE" sed "s|{{PHASE}}|$unit|g" "$EXECUTOR_PROMPT_FILE" fi } fix_prompt() { local errors="$1" { echo "Your previous attempt at this task was rejected by the harness." echo "The failures from the last attempt are below. Review them, fix the code, and re-run the full test suite and linter until everything is green. Do not start other tasks' work." echo echo "Failure output (may be truncated):" echo '```' printf '%s\n' "$errors" | tail -c 6000 echo '```' echo echo "When everything is green, reply with the same report as before (at most 15 lines: what was fixed, test/lint/coverage results, notable decisions, next pending task)." } } # --- child executor ----------------------------------------------------------- # run_child [resume-session] # Attempt 1: fresh session in .agents/phase-sessions/. # Attempt N>1: resumes the given session file — the failed executor's own # session, tracked by execute_unit (so retries keep its work). When no # session was captured (or FRESH_FIX=1) a fresh ephemeral session runs with # the failure context instead. # Progress (tool calls, assistant text, thinking, compaction) streams to the # terminal live via scripts/progress.mjs, which also writes the final # assistant message to: unit_report md # Child stderr → unit_report err # QUIET=1 suppresses the progress display (report file is still written). # Sets CHILD_RC (pi's exit code) and PROGRESS_RC (progress.mjs's exit code; # non-zero means the stream ended without a clean final report). # Run `pi … | progress.mjs`, capturing both exit codes. Must be the direct # caller of the pipeline, and PIPESTATUS must be copied in ONE statement — # any following command (even a plain assignment) resets it. _run_pi_pipeline() { local errf="$1"; shift "$@" 2>"$errf" | "${PROGRESS[@]}" local rcs=( "${PIPESTATUS[@]}" ) CHILD_RC=${rcs[0]} PROGRESS_RC=${rcs[1]} # Ctrl+C sends INT to the whole foreground group: pi and progress.mjs die # with 130, and bash then SUPPRESSES the script's INT trap (it assumes the # job already handled the signal). Detect the kill here and exit as if the # trap had fired, so the failure is always visible. if (( CHILD_RC == 130 || PROGRESS_RC == 130 )); then echo echo "✗ ERROR: interrupted (SIGINT) — unit is left in $PHASE_TODO/; re-run to continue" >&2 exit 130 fi if (( CHILD_RC == 143 || PROGRESS_RC == 143 )); then echo echo "✗ ERROR: interrupted (SIGTERM) — unit is left in $PHASE_TODO/; re-run to continue" >&2 exit 143 fi } run_child() { local unit="$1" attempt="$2" prompt="$3" resume_session="${4:-}" local base out errf base="$(unit_base "$unit")" out="$(unit_report "$unit" "$attempt" md)" errf="$(unit_report "$unit" "$attempt" err)" mkdir -p "$(dirname "$out")" local PROGRESS=(node "$SKILL_DIR/scripts/progress.mjs" "$out") [[ "${QUIET:-0}" == "1" ]] && PROGRESS+=(--quiet) if (( attempt == 1 )); then _run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session-dir "$PHASE_SESSIONS" --name "$base" --mode json "$prompt" elif [[ "${FRESH_FIX:-0}" == "1" || -z "$resume_session" || ! -f "$resume_session" ]]; then echo " (no resumable session — starting fresh)" _run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --no-session --mode json "$prompt" else echo " (resuming failed executor session: ${resume_session##*/})" _run_pi_pipeline "$errf" pi "${PI_ARGS[@]}" --session "$resume_session" --mode json "$prompt" fi } # --- validation gate ---------------------------------------------------------- ensure_validate() { if [[ ! -f .agents/validate.sh ]]; then cp "$SKILL_DIR/assets/validate.sh" .agents/validate.sh chmod +x .agents/validate.sh warn "no .agents/validate.sh found — created it from the skill template." warn "adapt it to this project's real test/lint/coverage commands; it is the pass/fail gate after every task." fi } # run_validation ; returns 0 iff .agents/validate.sh exits 0. run_validation() { bash .agents/validate.sh >"$1" 2>&1 } # --- phase commit + push ------------------------------------------------------ # Commit ownership: child executors NEVER commit (their prompts forbid it, # overriding any project instruction to commit per task/phase). By default # (PHASE_COMMIT=1) the harness therefore makes ONE atomic commit per # completed phase — the phase's code changes, the todo→complete file move, # and the executor reports, together — at the phase's commit point, and # pushes it right after (PHASE_PUSH=1, default — see push_phase). # # Staging is scoped so the owner's unrelated work is not swept in: # stage = (everything dirty now) # − (what was already dirty when the phase's first unit started — # snapshot $PHASE_SESSIONS/dirty-, taken once per phase) # − (pipeline runtime artifacts: phase-sessions/, pipeline.log) # Work the owner dirtied mid-phase is indistinguishable from phase work and # IS committed — the commit output prints exactly what went in. # Snapshot the worktree's dirty state at the phase's first unit (no-op # outside a git work tree, and when the phase already has a snapshot — a # resumed phase keeps the one taken at its start). phase_dirty_snapshot() { local phase="$1" f git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 f="$PHASE_SESSIONS/dirty-$phase" if [[ ! -f "$f" ]]; then git status --porcelain >"$f" 2>/dev/null || : >"$f" fi } # commit_phase # Returns 0 on success, when there is nothing left to commit, or outside a # git work tree. Returns 1 (after printing a ✗ ERROR block with the git # error and the hand-fix command) when the phase's own artifacts could not # be committed, or the push of the phase commit failed (the commit is local # and safe — the next phase's push sweeps it in once the push works). The # phase stays COMPLETE either way — the work passed validation; the caller # stops the run so a commit/push miss is visible, never retried as a task # and never swept into a later phase's commit. commit_phase() { local unit="$1" report="${2:-}" local phase pre curf paths lits moved staged addout commitout subject msgf phase="$(unit_phase "$unit")" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then warn "not a git work tree — no phase commit for $phase" return 0 fi pre="$PHASE_SESSIONS/dirty-$phase" # Everything dirty now: tracked changes vs HEAD + untracked non-ignored. curf="$(mktemp)" { git diff --name-only HEAD; git ls-files --others --exclude-standard; } | sort -u >"$curf" # Quick exit: the phase's artifacts are already committed (moved file # tracked and clean — e.g. a duplicate commit attempt after the snapshot # was cleaned up) — never stage again, owner WIP stays untouched. if git ls-files --error-unmatch -- "$PHASE_DONE/$unit" >/dev/null 2>&1 \ && ! grep -qx -- "$PHASE_DONE/$unit" "$curf"; then rm -f "$curf" echo " (nothing to commit — the phase is already fully committed)" return 0 fi # Subtract pre-phase owner WIP (porcelain lines are "XY ") and the # pipeline's own runtime artifacts. paths="$(mktemp)" if [[ -f "$pre" ]]; then comm -23 "$curf" <(cut -c4- "$pre" | grep -v '^[[:space:]]*$' | sort -u) \ | grep -vE '^\.agents/(phase-sessions/|pipeline\.log$)' >"$paths" || true else grep -vE '^\.agents/(phase-sessions/|pipeline\.log$)' "$curf" >"$paths" || true fi rm -f "$curf" if [[ ! -s "$paths" ]]; then rm -f "$paths" "$pre" echo " (nothing to commit — the phase is already fully committed)" return 0 fi # :(literal) keeps file names with glob characters ([?* ) from being read # as pathspec patterns. lits="$(mktemp)" sed 's|^|:(literal)|' "$paths" >"$lits" if ! addout="$(git add -A --pathspec-from-file="$lits" 2>&1)"; then rm -f "$paths" "$lits" echo "✗ ERROR: phase commit for $phase — staging failed:" >&2 printf '%s\n' "$addout" | sed 's/^/ /' >&2 return 1 fi rm -f "$lits" # Verify the artifacts that used to go missing: the moved phase file must # be in the index (hard fail), and the executor report unless the project # deliberately gitignores reports. moved="$PHASE_DONE/$unit" staged="$(git -c core.quotepath=off diff --cached --name-only -z | tr '\0' '\n')" if ! grep -qx -- "$moved" <<<"$staged"; then git reset -q rm -f "$paths" echo "✗ ERROR: phase commit for $phase — the moved phase file is not in the index ($moved)." >&2 if git check-ignore -q -- "$moved" 2>/dev/null; then echo " your .gitignore excludes it — .agents/phases must be tracked (SKILL.md setup notes)." >&2 fi echo " the work is complete and uncommitted; stage and commit it by hand." >&2 return 1 fi if [[ -n "$report" && -f "$report" ]] && ! grep -qx -- "$report" <<<"$staged" \ && ! git check-ignore -q -- "$report" 2>/dev/null; then git reset -q rm -f "$paths" echo "✗ ERROR: phase commit for $phase — the executor report is not in the index ($report)." >&2 echo " the work is complete and uncommitted; stage and commit it by hand." >&2 return 1 fi rm -f "$paths" # Subject: PHASE_COMMIT_SUBJECT with {{PHASE}} substituted (default # "phase: "); the executor's final report becomes the body. # The default subject lives in a variable: a brace literal inside the # ${var:-default} word would terminate the expansion early (bash does not # nest plain braces). local default_subject="phase: {{PHASE}}" subject="${PHASE_COMMIT_SUBJECT:-$default_subject}" subject="${subject//\{\{PHASE\}\}/$phase}" msgf="$(mktemp)" { printf '%s\n' "$subject" printf '\n' [[ -n "$report" && -f "$report" ]] && sed -n '1,40p' "$report" } >"$msgf" if ! commitout="$(git commit --no-gpg-sign -F "$msgf" 2>&1)"; then rm -f "$msgf" echo "✗ ERROR: phase commit for $phase FAILED — the phase is complete but UNCOMMITTED (the scoped changes are still staged)." >&2 printf '%s\n' "$commitout" | sed 's/^/ /' >&2 echo " finish by hand: git commit --no-gpg-sign -m 'phase: $phase'" >&2 echo " re-running the pipeline will NOT commit this phase — fix the commit first." >&2 return 1 fi rm -f "$msgf" "$pre" echo " (committed: $(git log -1 --oneline))" # Hand the commit to the remote (PHASE_PUSH, default on). A push failure # returns 1 so the run stops — same contract as a commit failure. if ! push_phase "$phase"; then return 1 fi return 0 } # push_phase # Pushes the phase commit made by commit_phase (PHASE_PUSH=1, default). # No remote configured → notice and success (the commit stays local). # Branch with an upstream → `git push`; without → `git push -u `. On failure prints a ✗ ERROR block with the git error # and the hand-fix command and returns 1 — the commit is local and safe, # and the next phase's push sweeps the unpushed commit in once the push # works. push_phase() { local phase="$1" remote branch cmd pushout [[ "${PHASE_PUSH:-1}" == "1" ]] || { echo " (PHASE_PUSH=0 — commit not pushed, left local)"; return 0; } remote="$(git remote 2>/dev/null | head -n1)" if [[ -z "$remote" ]]; then echo " (no git remote configured — skipping push)" return 0 fi branch="$(git symbolic-ref --short -q HEAD || true)" if [[ -z "$branch" ]]; then echo "✗ ERROR: phase push for $phase — detached HEAD, nothing to push." >&2 echo " the phase commit is local; push by hand once you are on a branch." >&2 return 1 fi if git rev-parse --abbrev-ref --disambiguate '@{u}' >/dev/null 2>&1; then cmd=(git push) else cmd=(git push -u "$remote" "$branch") fi if ! pushout="$("${cmd[@]}" 2>&1)"; then echo "✗ ERROR: phase push for $phase FAILED — the phase is committed LOCALLY but NOT PUSHED." >&2 printf '%s\n' "$pushout" | sed 's/^/ /' >&2 echo " finish by hand: ${cmd[*]}" >&2 echo " re-running will NOT re-push it now — the next phase's push sweeps it in once the push works; fix the push first." >&2 return 1 fi echo " (pushed: $(git log -1 --oneline))" return 0 } # --- notifications ------------------------------------------------------------ # Send a push notification via ntfy after a unit completes. Reads # ~/.env/pi-ntfy.env (see the ntfy skill). No-ops (silently) when notifications # are disabled (PHASE_NOTIFY=0) or ntfy is not configured, so it never breaks # the pipeline. Gated on PHASE_NOTIFY so a misconfigured server can't stall a # long run; default ON when the env file is present. notify_task() { local unit="$1" status="$2" # status: task | phase [[ "${PHASE_NOTIFY:-1}" == "1" ]] || return 0 local envf="${NTFY_ENV_FILE:-$HOME/.env/pi-ntfy.env}" [[ -f "$envf" ]] || return 0 # shellcheck disable=SC1090 source "$envf" [[ -n "${NTFY_URL:-}" && -n "${NTFY_TOKEN:-}" ]] || return 0 local title body tags if [[ "$status" == phase ]]; then title="Phase $(unit_phase "$unit") done" tags="heavy_check_mark,phase-complete" body="Phase $(unit_phase "$unit") of the pipeline completed." else title="Task complete" tags="heavy_check_mark,task-complete" body="Task delivered: $unit" fi curl -sS -X POST "${NTFY_URL}/${NTFY_TOPIC:-pi}" \ -H "Authorization: Bearer ${NTFY_TOKEN}" \ -H "X-Title: $title" \ -H "X-Priority: 4" \ -H "X-Tags: $tags" \ -H "X-Markdown: yes" \ -d "$body" >/dev/null 2>&1 || warn "ntfy notification failed for $unit" } # --- one unit (task, phase final pass, or legacy phase), with retries --------- # execute_unit # Returns 0 and moves the unit to complete/ on success; returns 1 after # MAX_FIX_ATTEMPTS failed attempts (unit file is left in todo/), or 1 when # the phase commit or its push fails at a phase's commit point (unit stays # in complete/ — the work is done; the commit/push must be finished by hand). execute_unit() { local unit="$1" local base attempt=1 errors="" local last_session="" pre post base="$(unit_base "$unit")" command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render task progress)" mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" "$(unit_report_dir "$unit")" ensure_validate # First unit of the phase: freeze the owner's pre-existing dirty state so # the phase commit (commit_phase) can exclude it from staging. phase_dirty_snapshot "$(unit_phase "$unit")" while (( attempt <= MAX_FIX_ATTEMPTS )); do echo "━━ $unit — attempt $attempt/$MAX_FIX_ATTEMPTS ━━" pre="$(latest_child_session)" if (( attempt == 1 )); then run_child "$unit" 1 "$(first_prompt "$unit")" else run_child "$unit" "$attempt" "$(fix_prompt "$errors")" "$last_session" fi # Track which session file this attempt used, so the next attempt resumes # exactly this unit's failed session (not just "latest in the directory"). post="$(latest_child_session)" if [[ -n "$post" && "$post" != "$pre" ]]; then last_session="$post" fi # The child can be signaled mid-flush: the session file then holds a final # report the stream lost. Recover it so a completed unit is not retried. if (( CHILD_RC == 0 )) && [[ -f "$(unit_report "$unit" "$attempt" md)" ]] \ && grep -q "no final assistant message" "$(unit_report "$unit" "$attempt" md)"; then if [[ -n "$last_session" ]] && recover_report "$(unit_report "$unit" "$attempt" md)" "$last_session"; then warn "stream lost the final message — report recovered from ${last_session##*/}" PROGRESS_RC=0 fi fi errors="" if (( CHILD_RC != 0 )); then warn "child pi exited with code $CHILD_RC — see $(unit_report "$unit" "$attempt" err)" errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$(unit_report "$unit" "$attempt" err)" 2>/dev/null)" fi if (( PROGRESS_RC != 0 )); then warn "child run ended without a clean final report — see $(unit_report "$unit" "$attempt" md)" errors+="[child run ended without a clean final report]"$'\n'"$(tail -c 4000 "$(unit_report "$unit" "$attempt" err)" 2>/dev/null)" fi if ! run_validation "$(unit_report "$unit" "$attempt" validate)"; then warn ".agents/validate.sh FAILED — see $(unit_report "$unit" "$attempt" validate)" errors+="[.agents/validate.sh FAILED]"$'\n'"$(tail -n 120 "$(unit_report "$unit" "$attempt" validate)" 2>/dev/null)" fi if [[ -z "$errors" ]]; then move_unit "$unit" if is_phase_end "$unit"; then echo "✓ $unit → complete (phase $(unit_phase "$unit") done)" if [[ "${PHASE_COMMIT:-1}" == "1" ]]; then if ! commit_phase "$unit" "$(unit_report "$unit" "$attempt" md)"; then # The work is done and validated; the unit stays in complete/ and # re-running will NOT re-execute this phase. Stop the run so the # commit miss is visible — commit_phase printed the hand-fix. echo " phase commit/push did not complete — fix it first (see above); re-running continues at the next phase" >&2 return 1 fi else # Explicit opt-out (PHASE_COMMIT=0): respect it, but never let the # miss be silent — a completed phase with uncommitted work used to # pile up in the worktree unnoticed. warn "PHASE_COMMIT=0 — phase $(unit_phase "$unit") is COMPLETE but UNCOMMITTED; its work is left in the worktree — commit it by hand" fi else echo "✓ $unit → complete" fi echo "── executor report ──" cat "$(unit_report "$unit" "$attempt" md)" return 0 fi attempt=$(( attempt + 1 )) done { echo "✗ $unit FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/." echo " last errors:" printf '%s\n' "$errors" | tail -n 40 | sed 's/^/ /' echo " logs: $(unit_report_dir "$unit")/$(unit_base "$unit").a*.{md,err,validate}" if [[ -n "${last_session:-}" && -f "${last_session:-}" ]]; then echo " resume: re-run this script (it auto-resumes the failed session), or manually:" echo " pi --session $last_session -c \"review the failures above, fix them, re-validate\"" else echo " resume: re-run this script to retry automatically" fi } >&2 return 1 }