#!/usr/bin/env bash # lib.sh — shared logic for the phased-execution skill. # Sourced by run-phase.sh and auto-phase.sh. Not meant to be run directly. # # Phase state lives in files, not chat context: # .agent/PLAN.md master plan, LOCKED DECISIONS (binding) # .agent/phases/todo/ pending phases, NN_name.md, sorted = execution order # .agent/phases/complete/ finished phases # .agent/reports/ per-phase executor reports, stderr, validation logs # .agent/phase-sessions/ child pi session files (resumable fixers) # # The pass/fail gate is .agent/validate.sh. A phase only moves to complete/ # after the child executor exits 0 AND validation passes. SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" EXECUTOR_PROMPT_FILE="$SKILL_DIR/assets/executor-prompt.md" PHASE_TODO=".agent/phases/todo" PHASE_DONE=".agent/phases/complete" PHASE_REPORTS=".agent/reports" PHASE_SESSIONS=".agent/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 .agent/phases/todo. find_root() { local d d="$(pwd)" while :; do if [[ -d "$d/.agent/phases/todo" ]]; then printf '%s\n' "$d"; return 0; fi [[ "$d" == "/" ]] && return 1 d="$(dirname "$d")" done } # --- phase selection ---------------------------------------------------------- # First pending phase (alphanumerical sort), or empty when none remain. next_phase() { ( cd "$PHASE_TODO" 2>/dev/null && ls -1 | grep -E '^[0-9]' | sort | head -n1 ) || true } # --- 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_prompt() { local phase="$1" [[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE" sed "s|{{PHASE}}|$phase|g" "$EXECUTOR_PROMPT_FILE" } fix_prompt() { local errors="$1" { echo "Your previous attempt at this phase 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 phases' 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 phase)." } } # --- child executor ----------------------------------------------------------- # run_child [resume-session] # Attempt 1: fresh session in .agent/phase-sessions/. # Attempt N>1: resumes the given session file — the failed executor's own # session, tracked by execute_phase (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: .agent/reports/.a.md # Child stderr → .agent/reports/.a.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) — phase 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) — phase is left in $PHASE_TODO/; re-run to continue" >&2 exit 143 fi } run_child() { local phase="$1" attempt="$2" prompt="$3" resume_session="${4:-}" local base="${phase%.md}" local out="$PHASE_REPORTS/$base.a$attempt.md" local errf="$PHASE_REPORTS/$base.a$attempt.err" 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 .agent/validate.sh ]]; then cp "$SKILL_DIR/assets/validate.sh" .agent/validate.sh chmod +x .agent/validate.sh warn "no .agent/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 for every phase." fi } # run_validation ; returns 0 iff .agent/validate.sh exits 0. run_validation() { bash .agent/validate.sh >"$1" 2>&1 } # --- one phase, with bounded fixer retries ------------------------------------ # execute_phase # Returns 0 and moves the phase to complete/ on success; returns 1 after # MAX_FIX_ATTEMPTS failed attempts (phase file is left in todo/). execute_phase() { local phase="$1" local base="${phase%.md}" local attempt=1 errors="" local last_session="" pre post command -v node >/dev/null 2>&1 || die "node not found on PATH (needed to render phase progress)" mkdir -p "$PHASE_DONE" "$PHASE_REPORTS" "$PHASE_SESSIONS" ensure_validate while (( attempt <= MAX_FIX_ATTEMPTS )); do echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━" pre="$(latest_child_session)" if (( attempt == 1 )); then run_child "$phase" 1 "$(first_prompt "$phase")" else run_child "$phase" "$attempt" "$(fix_prompt "$errors")" "$last_session" fi # Track which session file this attempt used, so the next attempt resumes # exactly this phase'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 phase is not retried. if (( CHILD_RC == 0 )) && [[ -f "$PHASE_REPORTS/$base.a$attempt.md" ]] \ && grep -q "no final assistant message" "$PHASE_REPORTS/$base.a$attempt.md"; then if [[ -n "$last_session" ]] && recover_report "$PHASE_REPORTS/$base.a$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 $PHASE_REPORTS/$base.a$attempt.err" errors+="[child pi exited with code $CHILD_RC]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)" fi if (( PROGRESS_RC != 0 )); then warn "child run ended without a clean final report — see $PHASE_REPORTS/$base.a$attempt.md" errors+="[child run ended without a clean final report]"$'\n'"$(tail -c 4000 "$PHASE_REPORTS/$base.a$attempt.err" 2>/dev/null)" fi if ! run_validation "$PHASE_REPORTS/$base.a$attempt.validate"; then warn ".agent/validate.sh FAILED — see $PHASE_REPORTS/$base.a$attempt.validate" errors+="[.agent/validate.sh FAILED]"$'\n'"$(tail -n 120 "$PHASE_REPORTS/$base.a$attempt.validate" 2>/dev/null)" fi if [[ -z "$errors" ]]; then mv -f "$PHASE_TODO/$phase" "$PHASE_DONE/$phase" echo "✓ $phase → complete" if [[ "${PHASE_COMMIT:-0}" == "1" ]]; then if git add -A 2>/dev/null && git commit --no-gpg-sign -m "phase: $phase" >/dev/null 2>&1; then echo " (committed)" else warn "git commit failed (continuing)" fi fi echo "── executor report ──" cat "$PHASE_REPORTS/$base.a$attempt.md" return 0 fi attempt=$(( attempt + 1 )) done { echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/." echo " last errors:" printf '%s\n' "$errors" | tail -n 40 | sed 's/^/ /' echo " logs: $PHASE_REPORTS/$base.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 }