Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions tools/spec-loop/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# https://www.apache.org/licenses/LICENSE-2.0
#
# Deterministic helpers for tools/spec-loop/loop.sh. Keep this file free of
# live agent execution so fixture tests can pin runner behaviour without
# launching a headless harness.

spec_loop_append_tooling_source_context() {
local mode=$1 base=$2 tooling_ref=$3

cat <<EOF

## Tooling source — read the plan and specs from here

This iteration builds on the integration base \`$base\`, which does
NOT carry the spec-loop tooling. The plan and specs live on the
control branch \`$tooling_ref\`. Read them from there with \`git show\`,
never from the working tree:

\`\`\`
git show $tooling_ref:tools/spec-loop/IMPLEMENTATION_PLAN.md
git ls-tree -r --name-only $tooling_ref tools/spec-loop/specs/
git show $tooling_ref:tools/spec-loop/specs/<file>
\`\`\`

EOF
if [ "$mode" = "update" ]; then
cat <<EOF
Read the current specs from \`$tooling_ref\` (commands above) as the
baseline, then author the updated spec files on this work branch —
the sync PR adds them to \`$base\`. Update is the one beat that
writes specs; do that here, not on the control branch.
EOF
else
cat <<EOF
Implement the product change on the work branch; do NOT edit specs
there — they are not on \`$base\`. The control branch owns the specs.
EOF
fi
}

spec_loop_assemble_prompt_file() {
local prompt_file=$1 dest=$2 mode=$3 build_iteration=$4 base=$5 tooling_ref=$6
local compact_file=$7 pr_file=$8 branch_file=$9 update_scope_file=${10:-}

cat "$prompt_file" > "$dest"
cat "$compact_file" >> "$dest"
if [ "$mode" != "update" ]; then
cat "$pr_file" >> "$dest"
fi
cat "$branch_file" >> "$dest"

if [ "$build_iteration" = "true" ] && [ "$tooling_ref" != "$base" ]; then
spec_loop_append_tooling_source_context "$mode" "$base" "$tooling_ref" >> "$dest"
fi
if [ "$mode" = "update" ] && [ -n "$update_scope_file" ]; then
cat "$update_scope_file" >> "$dest"
fi
}

spec_loop_write_last_sync_marker() {
local marker=$1 base_head=$2
mkdir -p "$(dirname "$marker")"
printf '%s\n' "$base_head" > "$marker"
}

spec_loop_marker_branch_name() {
local base_head=$1
printf 'advance-last-sync-%s\n' "${base_head:0:7}"
}

spec_loop_launch_agent() {
local harness=$1 agent=$2 root=$3 prompt_file=$4 model=$5 output_format=$6
local model_args=()
[ -n "$model" ] && model_args=(--model "$model")

if [ "$harness" = "opencode" ]; then
local oc_format_args=()
[ "$output_format" = "stream-json" ] && oc_format_args=(--format json)
"$agent" run \
--auto \
${model_args[@]+"${model_args[@]}"} \
${oc_format_args[@]+"${oc_format_args[@]}"} \
"$(cat "$prompt_file")" &
elif [ "$harness" = "codex" ]; then
local codex_format_args=()
[ "$output_format" = "stream-json" ] && codex_format_args=(--json)
"$agent" exec \
--dangerously-bypass-approvals-and-sandbox \
--cd "$root" \
${model_args[@]+"${model_args[@]}"} \
${codex_format_args[@]+"${codex_format_args[@]}"} \
- < "$prompt_file" &
elif [ "$harness" = "cursor" ]; then
local cursor_format_args=(--output-format "$output_format")
local cursor_subcommand=()
[ "$(basename "$agent")" = "cursor" ] && cursor_subcommand=(agent)
"$agent" \
${cursor_subcommand[@]+"${cursor_subcommand[@]}"} \
--print \
--force \
--trust \
--workspace "$root" \
${model_args[@]+"${model_args[@]}"} \
${cursor_format_args[@]+"${cursor_format_args[@]}"} \
"$(cat "$prompt_file")" &
elif [ "$harness" = "gemini" ]; then
"$agent" \
--yolo \
${model_args[@]+"${model_args[@]}"} \
--prompt "$(cat "$prompt_file")" &
else
local verbose_args=()
[ "$output_format" = "stream-json" ] && verbose_args=(--verbose)
"$agent" -p \
--dangerously-skip-permissions \
--disallowedTools "Bash(git push:*)" "Bash(gh:*)" \
--output-format="$output_format" \
${verbose_args[@]+"${verbose_args[@]}"} \
${model_args[@]+"${model_args[@]}"} < "$prompt_file" &
fi
SPEC_LOOP_AGENT_PID=$!
}
110 changes: 27 additions & 83 deletions tools/spec-loop/loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ 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
Expand Down Expand Up @@ -400,19 +402,24 @@ while true; do
# 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_WITH_CONTEXT"
elif ! git show "$TOOLING_REF:$ACTIVE_PROMPT" > "$PROMPT_WITH_CONTEXT" 2>/dev/null; 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"; break
rm -f "$PROMPT_WITH_CONTEXT" "$PROMPT_SOURCE"; break
fi
compact_inventory_context >> "$PROMPT_WITH_CONTEXT"
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 >> "$PROMPT_WITH_CONTEXT"
open_pr_context > "$PR_CONTEXT"
fi
local_branch_context >> "$PROMPT_WITH_CONTEXT"
local_branch_context > "$BRANCH_CONTEXT"

if [ "$BUILD_ITERATION" = true ]; then
# The work-item branch forks off BASE (e.g. main), which need not
Expand Down Expand Up @@ -454,7 +461,7 @@ while true; do
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"; break
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)"
Expand All @@ -463,10 +470,14 @@ while true; do
# 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 >> "$PROMPT_WITH_CONTEXT"
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
Expand All @@ -478,81 +489,14 @@ while true; do
# 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.
MODEL_ARGS=()
[ -n "$MODEL" ] && MODEL_ARGS=(--model "$MODEL")
if [ "$HARNESS" = "opencode" ]; then
# OpenCode headless: `opencode run <message>` takes the prompt as a
# positional argument (no stdin), `--model provider/model`, `--auto`
# auto-approves permissions not explicitly denied (the OpenCode
# equivalent of Claude's --dangerously-skip-permissions), and
# `--format json` matches the stream-json request. OpenCode has no
# per-invocation --disallowedTools; the push/gh denial that flag
# provides as defense-in-depth is instead enforced by the OS sandbox
# (the real guard — see the SECURITY header) plus, when wired, the
# agent-guard OpenCode plugin and the project's opencode.json
# `permission` config.
OC_FORMAT_ARGS=()
[ "$OUTPUT_FORMAT" = "stream-json" ] && OC_FORMAT_ARGS=(--format json)
"$AGENT" run \
--auto \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
${OC_FORMAT_ARGS[@]+"${OC_FORMAT_ARGS[@]}"} \
"$(cat "$PROMPT_WITH_CONTEXT")" &
elif [ "$HARNESS" = "codex" ]; then
# Codex CLI headless: `codex exec -` reads the prompt from stdin.
# `--dangerously-bypass-approvals-and-sandbox` is Codex's unattended
# automation switch; as with the other harnesses, only use this loop
# inside the external OS sandbox described in the SECURITY header.
# Codex has no per-invocation --disallowedTools equivalent here; the
# hard boundary is the OS sandbox plus repo hooks / exec policy.
CODEX_FORMAT_ARGS=()
[ "$OUTPUT_FORMAT" = "stream-json" ] && CODEX_FORMAT_ARGS=(--json)
"$AGENT" exec \
--dangerously-bypass-approvals-and-sandbox \
--cd "$ROOT" \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
${CODEX_FORMAT_ARGS[@]+"${CODEX_FORMAT_ARGS[@]}"} \
- < "$PROMPT_WITH_CONTEXT" &
elif [ "$HARNESS" = "cursor" ]; then
# Cursor Agent headless: `cursor agent --print <prompt>` or the
# standalone `cursor-agent --print <prompt>`. `--force` is Cursor's
# unattended "allow commands unless explicitly denied" mode, and
# `--trust` avoids a workspace-trust prompt in headless runs.
CURSOR_FORMAT_ARGS=(--output-format "$OUTPUT_FORMAT")
CURSOR_SUBCOMMAND=()
[ "$(basename "$AGENT")" = "cursor" ] && CURSOR_SUBCOMMAND=(agent)
"$AGENT" \
${CURSOR_SUBCOMMAND[@]+"${CURSOR_SUBCOMMAND[@]}"} \
--print \
--force \
--trust \
--workspace "$ROOT" \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
${CURSOR_FORMAT_ARGS[@]+"${CURSOR_FORMAT_ARGS[@]}"} \
"$(cat "$PROMPT_WITH_CONTEXT")" &
elif [ "$HARNESS" = "gemini" ]; then
# Gemini CLI headless: `gemini --prompt <prompt>` runs
# non-interactively; `--yolo` automatically approves tool calls.
"$AGENT" \
--yolo \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
--prompt "$(cat "$PROMPT_WITH_CONTEXT")" &
else
# Claude Code headless (the default).
VERBOSE_ARGS=()
[ "$OUTPUT_FORMAT" = "stream-json" ] && VERBOSE_ARGS=(--verbose)
"$AGENT" -p \
--dangerously-skip-permissions \
--disallowedTools "Bash(git push:*)" "Bash(gh:*)" \
--output-format="$OUTPUT_FORMAT" \
${VERBOSE_ARGS[@]+"${VERBOSE_ARGS[@]}"} \
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} < "$PROMPT_WITH_CONTEXT" &
fi
AGENT_PID=$!
# 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"
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)"
Expand All @@ -576,7 +520,7 @@ while true; do
# on BASE — make a tiny marker-only branch off BASE.
if [ "$MODE" = "update" ]; then
if [ "$CUR_BRANCH" != "$BASE" ] && [ "$CUR_BRANCH" != "$TOOLING_REF" ]; then
printf '%s\n' "$BASE_HEAD" > tools/spec-loop/.last-sync
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
Expand All @@ -589,9 +533,9 @@ while true; do
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="advance-last-sync-${BASE_HEAD:0:7}"
marker_branch="$(spec_loop_marker_branch_name "$BASE_HEAD")"
if git checkout -b "$marker_branch" >/dev/null 2>&1; then
printf '%s\n' "$BASE_HEAD" > tools/spec-loop/.last-sync
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"
Expand Down
10 changes: 8 additions & 2 deletions tools/spec-loop/specs/spec-loop-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ kind: feature
mode: infra
source: >
tools/spec-loop/README.md, tools/spec-loop/loop.sh,
tools/spec-loop/lib.sh, tools/spec-loop/tests/test_runner_fixtures.sh,
tools/spec-loop/PROMPT_plan.md, tools/spec-loop/PROMPT_build.md,
tools/spec-loop/PROMPT_update.md, tools/spec-loop/PROMPT_consolidate.md,
and docs/spec-driven-development.md.
Expand Down Expand Up @@ -41,6 +42,12 @@ and never opens a pull request.
## Where it lives

- `tools/spec-loop/loop.sh` — Bash runner for the four beats.
- `tools/spec-loop/lib.sh` — deterministic prompt assembly, harness
command rendering, agent launch, and `.last-sync` marker helpers used
by the runner and fixture tests.
- `tools/spec-loop/tests/test_runner_fixtures.sh` — deterministic
fixture tests for prompt assembly, harness command construction, and
`.last-sync` marker helpers; executed by `spec-validate`.
- `tools/spec-loop/PROMPT_plan.md` — gap-analysis prompt that rewrites
`IMPLEMENTATION_PLAN.md`.
- `tools/spec-loop/PROMPT_build.md` — implementation prompt for exactly
Expand Down Expand Up @@ -140,13 +147,12 @@ updating `loop.sh` in the same change.
```bash
bash -n tools/spec-loop/loop.sh
shellcheck tools/spec-loop/loop.sh
shellcheck tools/spec-loop/lib.sh tools/spec-loop/tests/test_runner_fixtures.sh
uv run --project tools/spec-validator --group dev spec-validate
```

## Known gaps

- There is no deterministic fixture test for prompt assembly, harness
command construction, or `.last-sync` marker amendment.
- The non-Claude harnesses rely on external policy/config plus the OS
sandbox for push/PR denial; only Claude has a per-invocation hard-deny
flag in the current runner.
Expand Down
Loading