feat: phases + tasks — per-task execution with phase directories

- A phase is now a directory: 00_phase.md (overview + task index) plus
  small, quick NN_task.md task files; complete/ mirrors todo/
- Task is the unit of execution: run-task.sh (one unit), run-phase.sh
  (phase to completion incl. 00_phase.md final pass), auto-phase.sh
  (all units in order); validate.sh gate runs after every task
- PHASE_COMMIT commits at phase boundaries (00_phase.md / legacy file moves)
- Legacy flat todo/NN_name.md files still execute as a single unit;
  new migrate-phases-to-tasks.sh converts them to the directory layout
- phase-status.sh reports per-task state and the next unit
- New task-template.md; phase templates now carry a Tasks index
This commit is contained in:
2026-08-23 19:28:01 -04:00
parent b4eb89a677
commit 85ac0d61b9
17 changed files with 696 additions and 223 deletions
+183 -64
View File
@@ -1,19 +1,28 @@
#!/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.
# 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:
# .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)
# .agent/PLAN.md master plan, LOCKED DECISIONS (binding)
# .agent/phases/todo/NN_name/ pending phase: 00_phase.md (overview) + NN_task.md task files
# .agent/phases/todo/NN_name.md legacy single-file phase (still executable)
# .agent/phases/complete/ finished phases — mirrors the todo/ layout
# .agent/reports/ per-task 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.
# The unit of execution is the TASK: each task file runs in its own pi
# subprocess (fresh context) with bounded fixer retries, and
# .agent/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 PHASE_COMMIT commit point.
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=".agent/phases/todo"
PHASE_DONE=".agent/phases/complete"
@@ -36,10 +45,100 @@ find_root() {
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
# --- unit selection -------------------------------------------------------------
# A unit is the smallest schedulable piece of work, referenced relative to
# .agent/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: <dir>/<base>.a<attempt>.<ext>.
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 -------------------------------------------------------
@@ -89,38 +188,52 @@ recover_report() {
}
# --- 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 phase="$1"
[[ -f "$EXECUTOR_PROMPT_FILE" ]] || die "missing $EXECUTOR_PROMPT_FILE"
sed "s|{{PHASE}}|$phase|g" "$EXECUTOR_PROMPT_FILE"
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 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 "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 phase)."
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 <phase> <attempt> <prompt> [resume-session]
# run_child <unit> <attempt> <prompt> [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, 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: .agent/reports/<base>.a<attempt>.md
# Child stderr → .agent/reports/<base>.a<attempt>.err
# assistant message to: unit_report <unit> <attempt> md
# Child stderr → unit_report <unit> <attempt> 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).
@@ -141,21 +254,23 @@ _run_pi_pipeline() {
# 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
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) — phase is left in $PHASE_TODO/; re-run to continue" >&2
echo "✗ ERROR: interrupted (SIGTERM) — unit 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 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)
@@ -176,7 +291,7 @@ ensure_validate() {
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."
warn "adapt it to this project's real test/lint/coverage commands; it is the pass/fail gate after every task."
fi
}
@@ -185,39 +300,39 @@ run_validation() {
bash .agent/validate.sh >"$1" 2>&1
}
# --- one phase, with bounded fixer retries ------------------------------------
# execute_phase <phase-file>
# 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=""
# --- one unit (task, phase final pass, or legacy phase), with retries ---------
# execute_unit <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/).
execute_unit() {
local unit="$1"
local base 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"
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
while (( attempt <= MAX_FIX_ATTEMPTS )); do
echo "━━ $phase — attempt $attempt/$MAX_FIX_ATTEMPTS ━━"
echo "━━ $unit — attempt $attempt/$MAX_FIX_ATTEMPTS ━━"
pre="$(latest_child_session)"
if (( attempt == 1 )); then
run_child "$phase" 1 "$(first_prompt "$phase")"
run_child "$unit" 1 "$(first_prompt "$unit")"
else
run_child "$phase" "$attempt" "$(fix_prompt "$errors")" "$last_session"
run_child "$unit" "$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").
# 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 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
# 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
@@ -225,40 +340,44 @@ execute_phase() {
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)"
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 $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)"
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 "$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)"
if ! run_validation "$(unit_report "$unit" "$attempt" validate)"; then
warn ".agent/validate.sh FAILED — see $(unit_report "$unit" "$attempt" validate)"
errors+="[.agent/validate.sh FAILED]"$'\n'"$(tail -n 120 "$(unit_report "$unit" "$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)"
move_unit "$unit"
if is_phase_end "$unit"; then
echo "✓ $unit → complete (phase $(unit_phase "$unit") done)"
if [[ "${PHASE_COMMIT:-0}" == "1" ]]; then
if git add -A 2>/dev/null && git commit --no-gpg-sign -m "phase: $(unit_phase "$unit")" >/dev/null 2>&1; then
echo " (committed)"
else
warn "git commit failed (continuing)"
fi
fi
else
echo "✓ $unit → complete"
fi
echo "── executor report ──"
cat "$PHASE_REPORTS/$base.a$attempt.md"
cat "$(unit_report "$unit" "$attempt" md)"
return 0
fi
attempt=$(( attempt + 1 ))
done
{
echo "✗ $phase FAILED after $MAX_FIX_ATTEMPTS attempts — left in $PHASE_TODO/."
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: $PHASE_REPORTS/$base.a*.{md,err,validate}"
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\""