Repository navigation
Expand file tree
/
Copy pathloop.sh
More file actions
executable file
·602 lines (559 loc) · 29 KB
/
Copy pathloop.sh
File metadata and controls
executable file
·602 lines (559 loc) · 29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
#!/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:
# Most profiles run the agent in unattended / auto-approval mode
# (for example Claude's `--dangerously-skip-permissions`, Codex's
# `--dangerously-bypass-approvals-and-sandbox`, or Cursor's `--force`).
# Gemini instead uses `--approval-mode default`; headless calls needing
# approval are refused. See docs/adapters/gemini.md#spec-loop-runner.
# Auto-approval 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`, `opencode`, `kiro`, or `grok`.
# Defaults from the agent basename, so setting
# SPEC_LOOP_AGENT=codex, SPEC_LOOP_AGENT=cursor-agent,
# SPEC_LOOP_AGENT=gemini, SPEC_LOOP_AGENT=opencode,
# SPEC_LOOP_AGENT=kiro-cli, or SPEC_LOOP_AGENT=grok 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_EFFORT reasoning-effort band: `low`, `medium`, or `high`.
# Unset means no effort flag is passed. Mapped per harness
# in lib.sh (CLIs without a knob silently omit it).
# 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 ;;
*grok*) HARNESS=grok ;;
*) HARNESS=claude ;;
esac
if [ -n "${SPEC_LOOP_MODEL:-}" ]; then
MODEL="$SPEC_LOOP_MODEL"
elif [ "$HARNESS" = "claude" ]; then
MODEL="sonnet"
else
MODEL=""
fi
EFFORT="${SPEC_LOOP_EFFORT:-}"
case "$EFFORT" in
''|low|medium|high) ;;
*)
echo "Error: SPEC_LOOP_EFFORT must be low, medium, or high (got '${EFFORT}')." >&2
exit 1
;;
esac
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 / kiro / grok / 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 [ -n "$EFFORT" ]; then echo "Effort: $EFFORT"; 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 ""
# Deterministic spec-scope mapping: pipe the diff through spec-scope so the
# agent receives a pre-computed list of likely-relevant specs rather than
# deriving the mapping itself. Failures are non-fatal (mapping is advisory).
local _changed_files _relevant_specs
_changed_files="$(git diff --name-only "$prev..$BASE_HEAD" -- .claude/skills tools docs/modes.md 2>/dev/null)"
if [ -n "$_changed_files" ]; then
_relevant_specs="$(printf '%s\n' "$_changed_files" | \
uv run --project tools/spec-inventory spec-scope 2>/dev/null)" || true
if [ -n "$_relevant_specs" ]; then
echo "The deterministic scope mapper (\`spec-scope\`) identified these specs"
echo "as likely relevant to the changed paths above:"
echo ""
echo '```'
printf '%s\n' "$_relevant_specs"
echo '```'
echo ""
fi
fi
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" "$EFFORT"
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