blob: fb5da88a573b0e6e8c85d5b53f4387c98bf2f7f5 [file]
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
#
# Spec-driven build loop for this repository.
#
# A small loop in the general "Ralph" style (run a fresh agent context
# against a prompt, repeat), adapted to this framework's posture:
#
# * FOUR modes, ONE mechanism:
# ./loop.sh build, unlimited iterations
# ./loop.sh 20 build, max 20 iterations
# ./loop.sh build [N] build, max N iterations (N=0/omitted = unlimited)
# ./loop.sh plan [N] gap-analysis only, updates the plan (default 1 pass; N=0 unlimited)
# ./loop.sh update [N] back-fill specs from code others contributed (default 1 pass; N=0 unlimited)
# ./loop.sh consolidate [N] shrink the plan (default 1 pass; N=0 unlimited)
# ./loop.sh -h | --help show usage and exit
# * BRANCH PER WORK ITEM: before each build iteration the loop returns
# to the integration base; the build prompt then carves out
# <slug> for the one work item it implements. One work item =
# one branch = one PR.
# * NEVER pushes, NEVER opens a PR. `git push` / `gh pr create` are in
# .claude/settings.json `ask` — they are the human's step. The loop
# ends at a local commit and the build prompt prints the human-run
# push + `gh pr create --web` commands.
# * NO REDOING BUILT WORK: because the loop never pushes, a work item it
# already built exists only as a LOCAL BRANCH and has no open PR. Each
# iteration therefore feeds the agent BOTH the open PRs and the local
# work-item branches as in-flight work. Without the local-branch signal
# the agent would re-pick the same top-priority plan item every
# iteration and rebuild it forever (an endless loop).
#
# SECURITY — read before running:
# This loop runs the agent in each harness's unattended / auto-approval mode
# (for example Claude's `--dangerously-skip-permissions`, Codex's
# `--dangerously-bypass-approvals-and-sandbox`, Cursor's `--force`, or
# Gemini's `--yolo`). That bypasses the agent permission layer but NOT the
# external OS sandbox (clean-env + filesystem/network). Per the project's
# security model it MUST be launched inside the sandbox harness, with no
# push/write credentials in the environment. Full rationale:
# docs/spec-driven-development.md § Security and unattended agent loops.
#
# Stop gracefully: press Ctrl+C, or `touch STOP` (exits after the current
# iteration finishes).
#
# Env overrides:
# SPEC_LOOP_BASE branch to fork work items from (default: main)
# SPEC_LOOP_AGENT agent CLI to run (default: claude). Any supported
# headless agent CLI works; the invocation convention is
# chosen by SPEC_LOOP_HARNESS below.
# SPEC_LOOP_HARNESS which harness's headless-run convention to use when
# invoking SPEC_LOOP_AGENT: `claude` (default), `codex`,
# `cursor`, `gemini`, or `opencode`. Defaults from the
# agent basename, so setting SPEC_LOOP_AGENT=codex,
# SPEC_LOOP_AGENT=cursor-agent, SPEC_LOOP_AGENT=gemini, or
# SPEC_LOOP_AGENT=opencode is usually enough.
# SPEC_LOOP_MODEL model passed to the agent CLI. Defaults to `sonnet` for
# Claude; all other harnesses use their configured default
# unless this is set.
# SPEC_LOOP_PR_LIMIT open PRs to list for duplicate-work checks (default: 100)
# SPEC_LOOP_PLAN_MAX plan line count that triggers ONE consolidation
# round before building (default: 500)
set -uo pipefail
trap 'echo -e "\nStopping loop..."; exit 0' INT TERM
# Operate from the repo root so the agent sees the whole tree.
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "Error: not inside a git repository." >&2; exit 1; }
cd "$ROOT" || exit 1
LOOP_DIR="tools/spec-loop"
PLAN="$LOOP_DIR/IMPLEMENTATION_PLAN.md"
# shellcheck source=tools/spec-loop/lib.sh
. "$LOOP_DIR/lib.sh"
current_branch() {
local branch
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" || return 1
if [ "$branch" = "HEAD" ]; then
return 0
fi
printf '%s\n' "$branch"
}
# Default the integration base to main — the repo's integration target —
# regardless of which branch the loop is launched from. Work items fork
# from here. Override with SPEC_LOOP_BASE to build on top of a different
# branch.
BASE="${SPEC_LOOP_BASE:-main}"
# The control branch: where loop.sh, the prompts, the plan and the specs
# live. Captured now, before the loop checks anything out, because a
# build/update iteration checks out BASE (e.g. main) — which need not carry
# the spec-loop tooling. Every tooling read below goes through this ref via
# `git show`, so the loop works when launched from a feature branch while
# building on main.
TOOLING_REF="$(current_branch)"
TOOLING_REF="${TOOLING_REF:-HEAD}"
AGENT="${SPEC_LOOP_AGENT:-claude}"
# Which harness's headless-run convention to use for $AGENT. Defaults from the
# agent basename so `SPEC_LOOP_AGENT=opencode` is enough; override explicitly
# with SPEC_LOOP_HARNESS when the CLI name doesn't match the convention.
case "${SPEC_LOOP_HARNESS:-$(basename "$AGENT")}" in
*codex*) HARNESS=codex ;;
*cursor*) HARNESS=cursor ;;
*gemini*) HARNESS=gemini ;;
*opencode*) HARNESS=opencode ;;
*kiro*) HARNESS=kiro ;;
*) HARNESS=claude ;;
esac
if [ -n "${SPEC_LOOP_MODEL:-}" ]; then
MODEL="$SPEC_LOOP_MODEL"
elif [ "$HARNESS" = "claude" ]; then
MODEL="sonnet"
else
MODEL=""
fi
PR_LIMIT="${SPEC_LOOP_PR_LIMIT:-100}"
# Agent output format. Default `text` is what the spinner expects; switch to
# `stream-json` (SPEC_LOOP_OUTPUT_FORMAT=stream-json) to see live tool-call
# events when debugging a slow or wedged run.
OUTPUT_FORMAT="${SPEC_LOOP_OUTPUT_FORMAT:-text}"
# Plan length that triggers ONE consolidation round before building. The
# consolidate beat preserves every planned work item, so a plan that is long
# because of *pending work* (not stale history) cannot shrink below this —
# hence the one-shot latch below, which avoids re-consolidating forever.
PLAN_CONSOLIDATE_THRESHOLD="${SPEC_LOOP_PLAN_MAX:-500}"
# ---- parse arguments -------------------------------------------------
usage() {
cat <<'EOF'
Spec-driven build loop.
Usage:
./loop.sh [N] build; N iterations (omit or 0 = unlimited)
./loop.sh build [N] same as above, explicit
./loop.sh plan [N] gap-analysis only, updates the plan (default 1; 0 = unlimited)
./loop.sh update [N] back-fill specs from contributed code (default 1; 0 = unlimited)
./loop.sh consolidate [N] shrink the plan (default 1; 0 = unlimited)
./loop.sh -h | --help show this help
Stop gracefully: Ctrl+C, or `touch STOP` (exits after the current iteration).
EOF
}
# The iteration count is the SECOND arg for the named modes, and the FIRST
# (bare number) for build. `build [N]` is also accepted so the count position
# is consistent across every mode. An unknown first argument is an error —
# it must never fall through to an unbounded build run.
case "${1:-}" in
-h|--help|help)
usage; exit 0 ;;
plan) MODE="plan"; PROMPT_FILE="$LOOP_DIR/PROMPT_plan.md"; MAX_ITERATIONS="${2:-1}" ;;
update) MODE="update"; PROMPT_FILE="$LOOP_DIR/PROMPT_update.md"; MAX_ITERATIONS="${2:-1}" ;;
consolidate) MODE="consolidate"; PROMPT_FILE="$LOOP_DIR/PROMPT_consolidate.md"; MAX_ITERATIONS="${2:-1}" ;;
build) MODE="build"; PROMPT_FILE="$LOOP_DIR/PROMPT_build.md"; MAX_ITERATIONS="${2:-0}" ;;
"") MODE="build"; PROMPT_FILE="$LOOP_DIR/PROMPT_build.md"; MAX_ITERATIONS=0 ;;
*)
if [[ "$1" =~ ^[0-9]+$ ]]; then
MODE="build"; PROMPT_FILE="$LOOP_DIR/PROMPT_build.md"; MAX_ITERATIONS="$1"
else
echo "Error: unknown argument '$1'." >&2
echo >&2
usage >&2
exit 1
fi ;;
esac
# Reject a non-numeric iteration count (e.g. `plan abc`, `build 2x`). Without
# this the value would flow into the integer comparisons below, error to
# stderr, and be silently treated as 0 — i.e. run unbounded instead of failing.
if ! [[ "$MAX_ITERATIONS" =~ ^[0-9]+$ ]]; then
echo "Error: iteration count must be a non-negative integer, got '${MAX_ITERATIONS}'." >&2
exit 1
fi
[ -f "$PROMPT_FILE" ] || { echo "Error: $PROMPT_FILE not found" >&2; exit 1; }
if ! command -v "$AGENT" >/dev/null 2>&1; then
echo "Error: agent CLI '$AGENT' not found on PATH." >&2
echo "Set SPEC_LOOP_AGENT to a headless agent CLI (claude / codex / cursor / gemini / opencode / a wrapper)" >&2
echo "and SPEC_LOOP_HARNESS to its run convention if the name doesn't imply it." >&2
exit 1
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Mode: $MODE"
echo "Prompt: $PROMPT_FILE"
echo "Base: $BASE (work items fork from here)"
echo "Agent: $AGENT"
if [ -n "$MODEL" ]; then echo "Model: $MODEL"; else echo "Model: (agent default)"; fi
if [ "$MAX_ITERATIONS" -gt 0 ]; then echo "Max: $MAX_ITERATIONS iterations"; else echo "Max: unlimited"; fi
echo "Stop: Ctrl+C or touch STOP"
echo "Note: this loop never pushes and never opens a PR."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
rm -f STOP
ITERATION=0
CONSOLIDATE_TRIED=false # one-shot latch; resets when the plan drops back under the limit
# spinner during silent agent calls
spinner() {
local pid=$1 i=0
# Index frames as array elements, not string offsets: ${frames:$i:1} is
# byte-based under a C/POSIX locale (common in the clean-env sandbox) and
# would slice a multibyte braille glyph into garbage.
local frames=(⠋ ⠏)
while kill -0 "$pid" 2>/dev/null; do
printf "\r %s working... (Ctrl+C to stop)" "${frames[i]}"
i=$(( (i+1) % ${#frames[@]} )); sleep 0.15
done
printf "\r \r"
}
open_pr_context() {
echo ""
echo "## Open pull-request context"
echo ""
echo "The runner collected this immediately before the iteration. Treat open"
echo "pull requests as in-flight work: do not add plan items that are already"
echo "substantially covered by an open PR, and do not pick a build item that"
echo "duplicates one."
echo ""
if ! command -v gh >/dev/null 2>&1; then
echo "- unavailable: gh CLI not found on PATH."
return 0
fi
local prs
prs="$(gh pr list \
--state open \
--limit "$PR_LIMIT" \
--json number,title,headRefName,baseRefName,url,isDraft \
--template '{{range .}}- #{{.number}} {{.title}} ({{.headRefName}} -> {{.baseRefName}}){{if .isDraft}} [draft]{{end}} {{.url}}{{"\n"}}{{end}}' \
2>/dev/null)"
if [ $? -ne 0 ]; then
echo "- unavailable: gh pr list failed. Check GitHub authentication or network access."
elif [ -z "$prs" ]; then
echo "- No open pull requests found."
else
printf '%s\n' "$prs"
fi
}
# Local work-item branches the loop has already built. This is the companion
# to open_pr_context: the loop never pushes, so a freshly built item has NO
# open PR and is invisible to the PR check above. Listing it here as in-flight
# work is what stops the agent re-picking the same top-priority plan item and
# rebuilding it on a new branch every iteration. Reads refs only, so it is
# correct regardless of which branch is currently checked out.
# Incremental scope for `update`: read the saved sync marker and tell the
# agent to only re-inspect paths that changed since then. Without this the
# update beat re-audits every skill, tool, and modes.md row on every run,
# which is the bulk of the streaming volume on a slow link.
#
# The marker is `tools/spec-loop/.last-sync` — a plain file containing the
# BASE SHA the specs were last synced against. Read from the working tree
# (so a half-finished sync counts); fall back to the control branch via
# `git show` for the case where BASE is already checked out. The update
# runner amends or creates this file at the end of every successful sync.
update_scope_context() {
echo ""
echo "## Incremental scope — only re-inspect what changed since the last sync"
echo ""
local marker="tools/spec-loop/.last-sync"
local prev
if [ -f "$marker" ]; then
prev="$(tr -d '[:space:]' < "$marker")"
else
prev="$(git show "$TOOLING_REF:$marker" 2>/dev/null | tr -d '[:space:]')"
fi
if [ -z "$prev" ]; then
echo "No \`$marker\` recorded — do a full inventory. The runner will"
echo "write the new BASE SHA after the sync commit finishes."
return 0
fi
echo "The last sync marker (\`$marker\`) is \`$prev\`. The current BASE HEAD"
echo "is \`$BASE_HEAD\`."
echo ""
echo "Scope your inventory to paths touched in that range:"
echo ""
echo '```'
echo "git diff --name-only $prev..$BASE_HEAD -- .claude/skills tools docs/modes.md"
echo '```'
echo ""
echo "Skills, tools, and \`docs/modes.md\` rows untouched in that range are still"
echo "in sync as of the previous run — skip them. Only re-inspect specs whose"
echo "subjects appear in the diff. If the diff is empty there is nothing to"
echo "sync; exit without creating a branch or commit."
echo ""
echo "Do not edit \`$marker\`; the runner owns it and will amend or create"
echo "the marker commit after the sync finishes."
}
local_branch_context() {
echo ""
echo "## Local work-item branches"
echo ""
echo "The runner collected this immediately before the iteration. This loop"
echo "never pushes and never opens a PR, so a work item it has already built"
echo "exists ONLY as a local branch and will NOT appear in the open-PR"
echo "context above. Treat every branch listed here as work that is already"
echo "built or in flight: do not add a plan item, and do not pick a build"
echo "item, whose slug matches one of these branches or whose change one of"
echo "them already carries. Checking these branches (not just open PRs) is"
echo "what keeps the loop from rebuilding the same item every iteration."
echo ""
local have_base=false
if git rev-parse --verify --quiet "refs/heads/$BASE" >/dev/null 2>&1; then
have_base=true
fi
# Every local branch except the integration base and the control branch
# (where the tooling lives); those two are never work-item branches.
local branches
branches="$(git for-each-ref --format='%(refname:short)' refs/heads/ \
| grep -vxF "$BASE" \
| grep -vxF "$TOOLING_REF")"
if [ -z "$branches" ]; then
echo "- No local work-item branches found."
return 0
fi
local b subject ahead
while IFS= read -r b; do
[ -n "$b" ] || continue
subject="$(git log -1 --format='%s' "$b" 2>/dev/null)"
if [ "$have_base" = true ]; then
ahead="$(git rev-list --count "$BASE..$b" 2>/dev/null)"
echo "- ${b} (${ahead:-?} commit(s) ahead of ${BASE}): ${subject}"
else
echo "- ${b}: ${subject}"
fi
done <<< "$branches"
}
compact_inventory_context() {
echo ""
if ! command -v uv >/dev/null 2>&1; then
echo "## Compact repository inventory"
echo ""
echo "- unavailable: uv CLI not found on PATH."
return 0
fi
local inventory
if inventory="$(uv run --project tools/spec-inventory spec-inventory --brief --max-where 1 --max-validation 1 --max-gaps 1 2>/dev/null)"; then
printf '%s\n' "$inventory"
else
echo "## Compact repository inventory"
echo ""
echo "- unavailable: spec-inventory failed. Fall back to direct file reads."
fi
}
while true; do
if [ "$MAX_ITERATIONS" -gt 0 ] && [ "$ITERATION" -ge "$MAX_ITERATIONS" ]; then
echo "Reached max iterations: $MAX_ITERATIONS"; break
fi
if [ -f STOP ]; then echo "STOP file detected — exiting."; rm -f STOP; break; fi
ITERATION=$((ITERATION + 1))
echo ""
echo "━━━━━━━━━━━━━━━━━━━━ LOOP $ITERATION [ $(date '+%H:%M:%S') ] ━━━━━━━━━━━━━━━━━━━━"
ACTIVE_PROMPT="$PROMPT_FILE"
if [ "$MODE" = "build" ]; then
# Consolidate at most ONCE when the plan grows too long, then build
# even if it is still over: the remaining length is planned work
# items, which the consolidate beat preserves by design. The latch
# resets once the plan drops back under the limit (e.g. after items
# merge and a plan pass prunes them), so we never re-consolidate in a
# loop without making progress. Prefer the working-tree plan (so
# local edits count); fall back to the control branch ($TOOLING_REF)
# if the tree is on a base (e.g. main) that lacks the spec-loop tooling.
PLAN_LINES=$( { [ -f "$PLAN" ] && cat "$PLAN" || git show "$TOOLING_REF:$PLAN" 2>/dev/null; } | wc -l )
if [ "$PLAN_LINES" -le "$PLAN_CONSOLIDATE_THRESHOLD" ]; then
CONSOLIDATE_TRIED=false
elif [ "$CONSOLIDATE_TRIED" = false ]; then
echo " [plan] $PLAN is ${PLAN_LINES} lines (> ${PLAN_CONSOLIDATE_THRESHOLD}) — one consolidation round"
ACTIVE_PROMPT="$LOOP_DIR/PROMPT_consolidate.md"
CONSOLIDATE_TRIED=true
else
echo " [plan] $PLAN still ${PLAN_LINES} lines after consolidation — length is planned work; building"
fi
fi
# A genuine build/update iteration (not a consolidate swap-in) carves a
# work-item branch off BASE. Plan/consolidate beats — and the consolidate
# swap-in — instead stay on the control branch, where the tooling lives.
BUILD_ITERATION=false
if { [ "$MODE" = "build" ] || [ "$MODE" = "update" ]; } && [ "$ACTIVE_PROMPT" = "$PROMPT_FILE" ]; then
BUILD_ITERATION=true
fi
# Assemble the prompt BEFORE any checkout, while the working tree is still
# on the control branch. Prefer the working-tree copy (local edits count);
# fall back to the control branch ($TOOLING_REF) if the tree is on a base
# that lacks the tooling. Either way the read never breaks after checkout.
PROMPT_WITH_CONTEXT="$(mktemp "${TMPDIR:-/tmp}/spec-loop-prompt.XXXXXX")" || exit 1
PROMPT_SOURCE="$(mktemp "${TMPDIR:-/tmp}/spec-loop-source.XXXXXX")" || exit 1
if [ -f "$ACTIVE_PROMPT" ]; then
cat "$ACTIVE_PROMPT" > "$PROMPT_SOURCE"
elif ! git show "$TOOLING_REF:$ACTIVE_PROMPT" > "$PROMPT_SOURCE" 2>/dev/null; then
echo "Error: could not read '$ACTIVE_PROMPT' from the working tree or control branch '$TOOLING_REF'." >&2
rm -f "$PROMPT_WITH_CONTEXT" "$PROMPT_SOURCE"; break
fi
COMPACT_CONTEXT="$(mktemp "${TMPDIR:-/tmp}/spec-loop-compact.XXXXXX")" || exit 1
PR_CONTEXT="$(mktemp "${TMPDIR:-/tmp}/spec-loop-prs.XXXXXX")" || exit 1
BRANCH_CONTEXT="$(mktemp "${TMPDIR:-/tmp}/spec-loop-branches.XXXXXX")" || exit 1
UPDATE_SCOPE_CONTEXT="$(mktemp "${TMPDIR:-/tmp}/spec-loop-update-scope.XXXXXX")" || exit 1
compact_inventory_context > "$COMPACT_CONTEXT"
# Update mode just diffs code against specs; it doesn't pick a work item, so
# the open-PR list (a network round-trip via gh) buys nothing. Skip it there.
if [ "$MODE" != "update" ]; then
open_pr_context > "$PR_CONTEXT"
fi
local_branch_context > "$BRANCH_CONTEXT"
if [ "$BUILD_ITERATION" = true ]; then
# The work-item branch forks off BASE (e.g. main), which need not
# carry the spec-loop tooling. Tell the agent to read the plan and
# specs from the control branch, not the working tree.
if [ "$TOOLING_REF" != "$BASE" ]; then
{
echo ""
echo "## Tooling source — read the plan and specs from here"
echo ""
echo "This iteration builds on the integration base \`$BASE\`, which does"
echo "NOT carry the spec-loop tooling. The plan and specs live on the"
echo "control branch \`$TOOLING_REF\`. Read them from there with \`git show\`,"
echo "never from the working tree:"
echo ""
echo '```'
echo "git show $TOOLING_REF:tools/spec-loop/IMPLEMENTATION_PLAN.md"
echo "git ls-tree -r --name-only $TOOLING_REF tools/spec-loop/specs/"
echo "git show $TOOLING_REF:tools/spec-loop/specs/<file>"
echo '```'
echo ""
if [ "$MODE" = "update" ]; then
echo "Read the current specs from \`$TOOLING_REF\` (commands above) as the"
echo "baseline, then author the updated spec files on this work branch —"
echo "the sync PR adds them to \`$BASE\`. Update is the one beat that"
echo "writes specs; do that here, not on the control branch."
else
echo "Implement the product change on the work branch; do NOT edit specs"
echo "there — they are not on \`$BASE\`. The control branch owns the specs."
fi
} >> "$PROMPT_WITH_CONTEXT"
fi
# Check out the base now — right before the agent runs, not earlier —
# so the reads above came from the control branch. The agent then
# forks its own <slug> branch off this base.
if [ "$(current_branch)" != "$BASE" ]; then
if ! checkout_out="$(git checkout "$BASE" 2>&1)"; then
echo "Error: could not check out base '$BASE'. git reported:" >&2
printf ' %s\n' "$checkout_out" >&2
echo "Resolve the working tree (commit or stash changes), then re-run." >&2
rm -f "$PROMPT_WITH_CONTEXT" "$PROMPT_SOURCE" "$COMPACT_CONTEXT" "$PR_CONTEXT" "$BRANCH_CONTEXT" "$UPDATE_SCOPE_CONTEXT"; break
fi
fi
BASE_HEAD="$(git rev-parse HEAD)"
# Update-only: append incremental scope now that BASE is checked out
# and BASE_HEAD is known. The agent will diff $prev..$BASE_HEAD on the
# listed paths and skip anything not touched in that range.
if [ "$MODE" = "update" ]; then
update_scope_context > "$UPDATE_SCOPE_CONTEXT"
fi
fi
spec_loop_assemble_prompt_file \
"$PROMPT_SOURCE" "$PROMPT_WITH_CONTEXT" "$MODE" "$BUILD_ITERATION" "$BASE" "$TOOLING_REF" \
"$COMPACT_CONTEXT" "$PR_CONTEXT" "$BRANCH_CONTEXT" "$UPDATE_SCOPE_CONTEXT"
# Run one iteration with a fresh context.
# -p headless / non-interactive
# --dangerously-skip-permissions let the agent edit + validate
# unattended. Bypasses the AGENT
# permission layer, NOT the OS sandbox
# (see the SECURITY header above).
# --disallowedTools … defense-in-depth: hard-deny push and
# gh so a stray call cannot reach the
# remote even with permissions skipped.
# Claude CLI requires --verbose with -p when output-format is stream-json;
# add it only in that case to keep the default `text` run quiet.
# Harness-specific launch details live in lib.sh so fixture tests can
# validate argv construction without starting an agent.
spec_loop_launch_agent "$HARNESS" "$AGENT" "$ROOT" "$PROMPT_WITH_CONTEXT" "$MODEL" "$OUTPUT_FORMAT"
AGENT_PID=$SPEC_LOOP_AGENT_PID
spinner "$AGENT_PID" & SPINNER_PID=$!
wait "$AGENT_PID"
wait "$SPINNER_PID" 2>/dev/null
rm -f "$PROMPT_WITH_CONTEXT" "$PROMPT_SOURCE" "$COMPACT_CONTEXT" "$PR_CONTEXT" "$BRANCH_CONTEXT" "$UPDATE_SCOPE_CONTEXT"
CUR_BRANCH="$(current_branch)"
LAST_COMMIT="$(git log --oneline -1 2>/dev/null)"
echo "[ branch ] $CUR_BRANCH"
[ -n "$LAST_COMMIT" ] && echo "[ commit ] $LAST_COMMIT"
if [ "$BUILD_ITERATION" = true ]; then
# Report the work-item branch the agent produced, by name, so you know
# exactly what to push.
if [ "$CUR_BRANCH" != "$BASE" ] && [ "$CUR_BRANCH" != "$TOOLING_REF" ]; then
echo "[ new branch ] $CUR_BRANCH (forked off $BASE)"
echo " push it with: git push -u origin $CUR_BRANCH"
else
echo "⚠ No work-item branch was created (still on '$CUR_BRANCH'). Check the agent output above." >&2
fi
# Update mode: advance the .last-sync marker to $BASE_HEAD, so the
# next `update` run scopes from here. Bundle the marker bump into
# the agent's sync commit when there is one (so it ships in the
# same PR); otherwise — if the agent saw nothing to sync and stayed
# on BASE — make a tiny marker-only branch off BASE.
if [ "$MODE" = "update" ]; then
if [ "$CUR_BRANCH" != "$BASE" ] && [ "$CUR_BRANCH" != "$TOOLING_REF" ]; then
spec_loop_write_last_sync_marker tools/spec-loop/.last-sync "$BASE_HEAD"
if ! git diff --quiet -- tools/spec-loop/.last-sync 2>/dev/null; then
git add tools/spec-loop/.last-sync
if git commit --amend --no-edit >/dev/null 2>&1; then
echo "[ marker ] amended .last-sync = $BASE_HEAD into $CUR_BRANCH"
else
echo "⚠ Could not amend .last-sync into '$CUR_BRANCH' — bump it by hand." >&2
fi
fi
elif [ "$CUR_BRANCH" = "$BASE" ]; then
cur_marker=""
[ -f tools/spec-loop/.last-sync ] && cur_marker="$(tr -d '[:space:]' < tools/spec-loop/.last-sync)"
if [ "$cur_marker" != "$BASE_HEAD" ]; then
marker_branch="$(spec_loop_marker_branch_name "$BASE_HEAD")"
if git checkout -b "$marker_branch" >/dev/null 2>&1; then
spec_loop_write_last_sync_marker tools/spec-loop/.last-sync "$BASE_HEAD"
git add tools/spec-loop/.last-sync
if git commit -m "chore(spec-loop): advance .last-sync to $BASE_HEAD" >/dev/null 2>&1; then
echo "[ marker ] advanced .last-sync on $marker_branch"
echo " push it with: git push -u origin $marker_branch"
CUR_BRANCH="$marker_branch"
fi
fi
fi
fi
fi
# Safety guard: a build/update iteration must never commit to the base.
if [ "$CUR_BRANCH" = "$BASE" ] && [ "$(git rev-parse HEAD)" != "$BASE_HEAD" ]; then
echo "✗ This iteration committed to '$BASE' instead of a work-item branch." >&2
echo " Stopping so you can review (expected a new <slug> branch)." >&2
break
fi
# Return to the control branch so the tooling (plan, prompts, specs) is
# present again and you are never stranded on the base. The work-item
# branch the agent created persists; this only moves HEAD.
if [ "$(current_branch)" != "$TOOLING_REF" ]; then
git checkout "$TOOLING_REF" >/dev/null 2>&1 || \
echo "⚠ Could not return to control branch '$TOOLING_REF' (now on '$(current_branch)')." >&2
fi
fi
echo ""
done