@@ -17,7 +17,10 @@
# 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.
# 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 (see commit_phase).
SKILL_DIR = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) /.. " && pwd ) "
EXECUTOR_PROMPT_FILE = " $SKILL_DIR /assets/executor-prompt.md "
@@ -300,6 +303,147 @@ run_validation() {
bash .agents/validate.sh >" $1 " 2>& 1
}
# --- phase commit -------------------------------------------------------------
# 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.
#
# 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-<phase>, 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 <unit> <report-file>
# 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. The phase stays COMPLETE either way — the work passed
# validation; the caller stops the run so a commit 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 <path>") 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: <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) ) "
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
@@ -337,7 +481,9 @@ notify_task() {
# --- 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/).
# MAX_FIX_ATTEMPTS failed attempts (unit file is left in todo/), or 1 when
# the phase commit fails at a phase's commit point (unit stays in
# complete/ — the work is done; the commit must be finished by hand).
execute_unit( ) {
local unit = " $1 "
local base attempt = 1 errors = ""
@@ -346,6 +492,9 @@ execute_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 ━━ "
@@ -390,12 +539,19 @@ execute_unit() {
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)"
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 work is UNCOMMITTED — fix the commit 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 "