feat(phased-execution): push each phase commit after it is made

The harness now pushes the phase commit right after committing it
(PHASE_PUSH=1 default, new push_phase in lib.sh): upstream when set,
else 'git push -u <first remote> <branch>'; no remote = skip with a
notice. A push failure keeps the commit local, prints the same loud
ERROR contract as a commit failure, and stops the run — the next
phase's push sweeps the unpushed commit in. PHASE_PUSH=0 opts out with
a loud notice. SKILL.md (Commits + config table + exit codes) and the
executor prompts document the new behavior.
This commit is contained in:
2026-09-10 07:56:30 -04:00
parent f6cbb2d664
commit b88fbaec62
8 changed files with 80 additions and 22 deletions
+56 -9
View File
@@ -20,7 +20,8 @@
# 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).
# 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"
@@ -303,12 +304,13 @@ run_validation() {
bash .agents/validate.sh >"$1" 2>&1
}
# --- phase commit -------------------------------------------------------------
# --- 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 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)
@@ -334,9 +336,11 @@ phase_dirty_snapshot() {
# 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.
# 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
@@ -441,6 +445,49 @@ commit_phase() {
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 <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 <first
# remote> <branch>`. 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
}
@@ -482,8 +529,8 @@ notify_task() {
# 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/), 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).
# 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=""
@@ -544,7 +591,7 @@ execute_unit() {
# 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
echo " phase commit/push did not complete — fix it first (see above); re-running continues at the next phase" >&2
return 1
fi
else