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
16 changes: 16 additions & 0 deletions docs/adapters/add-a-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,34 @@ Add a branch inside `spec_loop_launch_agent()` following the existing
patterns. Each branch runs the agent in the background (`&`) with:
- the auto-approve / skip-permissions flag for that runtime;
- `--model "$model"` forwarded when non-empty;
- `SPEC_LOOP_EFFORT` mapped when the CLI has a reasoning-effort knob
(see the effort table in
[`tools/spec-loop/specs/spec-loop-runner.md`](../../tools/spec-loop/specs/spec-loop-runner.md));
if the CLI has no such flag, omit silently like Cursor/Gemini — do not
warn;
- the prompt fed via stdin, a file argument, or a flag — whatever the
runtime accepts.

Example template:

```bash
elif [ "$harness" = "<runtime>" ]; then
local effort_args=()
case "$effort" in
low) effort_args=(--<effort-flag> <low-value>) ;;
medium) effort_args=(--<effort-flag> <medium-value>) ;;
high) effort_args=(--<effort-flag> <high-or-top-value>) ;;
esac
"$agent" <headless-flag> \
${model_args[@]+"${model_args[@]}"} \
${effort_args[@]+"${effort_args[@]}"} \
"$(cat "$prompt_file")" &
```

Take the flag name and accepted values from the runtime's own `--help`,
record them in the effort matrix, and map framework `high` to the CLI's
top sensible value when it offers more levels than `low|medium|high`.

Validate the loop syntax after editing:

```bash
Expand Down
8 changes: 8 additions & 0 deletions tools/spec-loop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ for Claude, as defence in depth, the loop also hard-denies `git push` and
- `SPEC_LOOP_MODEL` — model passed to the agent CLI. Defaults to `sonnet`
for Claude; Codex/Cursor/Gemini/OpenCode 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 (Claude/Kiro
`--effort`, Codex `-c model_reasoning_effort=…`, OpenCode `--variant`;
Cursor/Gemini have no knob and silently omit). Framework `high` maps
to each CLI's top value (`max` / `xhigh`); `low`/`medium` are not always
pass-through (OpenCode `low` → `minimal`). Exact values are in the
effort table in
[`specs/spec-loop-runner.md`](specs/spec-loop-runner.md).
- `SPEC_LOOP_PR_LIMIT` — number of open PRs to include in duplicate-work
checks (default `100`).
- `SPEC_LOOP_PLAN_MAX` — plan line count that triggers one consolidation
Expand Down
34 changes: 33 additions & 1 deletion tools/spec-loop/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,27 +72,43 @@ spec_loop_marker_branch_name() {

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

if [ "$harness" = "opencode" ]; then
local oc_format_args=()
local oc_effort_args=()
[ "$output_format" = "stream-json" ] && oc_format_args=(--format json)
case "$effort" in
low) oc_effort_args=(--variant minimal) ;;
medium) oc_effort_args=(--variant medium) ;;
high) oc_effort_args=(--variant max) ;;
esac
"$agent" run \
--auto \
${model_args[@]+"${model_args[@]}"} \
${oc_format_args[@]+"${oc_format_args[@]}"} \
${oc_effort_args[@]+"${oc_effort_args[@]}"} \
"$(cat "$prompt_file")" &
elif [ "$harness" = "codex" ]; then
local codex_format_args=()
local codex_effort_args=()
[ "$output_format" = "stream-json" ] && codex_format_args=(--json)
case "$effort" in
low) codex_effort_args=(-c model_reasoning_effort=low) ;;
medium) codex_effort_args=(-c model_reasoning_effort=medium) ;;
high) codex_effort_args=(-c model_reasoning_effort=xhigh) ;;
esac
"$agent" exec \
--dangerously-bypass-approvals-and-sandbox \
--cd "$root" \
${model_args[@]+"${model_args[@]}"} \
${codex_format_args[@]+"${codex_format_args[@]}"} \
${codex_effort_args[@]+"${codex_effort_args[@]}"} \
- < "$prompt_file" &
elif [ "$harness" = "cursor" ]; then
# Cursor has no per-invocation effort/thinking-level flag.
local cursor_format_args=(--output-format "$output_format")
local cursor_subcommand=()
[ "$(basename "$agent")" = "cursor" ] && cursor_subcommand=(agent)
Expand All @@ -106,6 +122,7 @@ spec_loop_launch_agent() {
${cursor_format_args[@]+"${cursor_format_args[@]}"} \
"$(cat "$prompt_file")" &
elif [ "$harness" = "gemini" ]; then
# Gemini CLI has no per-invocation effort/thinking-level flag.
"$agent" \
--yolo \
${model_args[@]+"${model_args[@]}"} \
Expand All @@ -114,17 +131,32 @@ spec_loop_launch_agent() {
# Kiro CLI headless: positional prompt to `kiro-cli chat --no-interactive`.
# The model is selected by the chosen agent config (.kiro/agents), not a
# per-invocation flag, and Kiro has no stream-json output mode here.
# Effort is a first-class `--effort` flag (low|medium|high|xhigh|max).
local kiro_effort_args=()
case "$effort" in
low) kiro_effort_args=(--effort low) ;;
medium) kiro_effort_args=(--effort medium) ;;
high) kiro_effort_args=(--effort max) ;;
esac
"$agent" chat --no-interactive \
${kiro_effort_args[@]+"${kiro_effort_args[@]}"} \
"$(cat "$prompt_file")" &
else
local verbose_args=()
local claude_effort_args=()
[ "$output_format" = "stream-json" ] && verbose_args=(--verbose)
case "$effort" in
low) claude_effort_args=(--effort low) ;;
medium) claude_effort_args=(--effort medium) ;;
high) claude_effort_args=(--effort max) ;;
esac
"$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" &
${model_args[@]+"${model_args[@]}"} \
${claude_effort_args[@]+"${claude_effort_args[@]}"} < "$prompt_file" &
fi
SPEC_LOOP_AGENT_PID=$!
}
14 changes: 13 additions & 1 deletion tools/spec-loop/loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
# 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)
Expand Down Expand Up @@ -116,6 +119,14 @@ elif [ "$HARNESS" = "claude" ]; then
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
Expand Down Expand Up @@ -190,6 +201,7 @@ 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."
Expand Down Expand Up @@ -492,7 +504,7 @@ while true; do
# 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"
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"
Expand Down
27 changes: 25 additions & 2 deletions tools/spec-loop/specs/spec-loop-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,33 @@ sandbox, and stop without pushing or opening a PR.
| OpenCode | positional prompt to `opencode run` | launched from repo root | `--auto` | `--model` | `--format json` for stream JSON | external sandbox and OpenCode policy |
| Kiro | positional prompt to `kiro-cli chat` | launched from repo root | `--no-interactive` | agent config (`.kiro/agents`) | plain text only | external sandbox and the agent-guard `--kiro` hook |

`SPEC_LOOP_EFFORT` is the harness-neutral reasoning-effort band
(`low` / `medium` / `high`). Unset means no effort flag is added. Invalid
values fail at startup before the first iteration. Where a CLI offers more
levels than the framework band, `high` maps to that CLI's top value.

Flag names and accepted values below were taken from each CLI's own
`--help` (or the vendor docs when the binary was unavailable) at the time
this mapping landed. They are version-dependent: an installed CLI that
rejects a mapped value will fail the agent launch mid-loop. When bumping a
harness CLI, re-check `--help` and update this table and
`spec_loop_launch_agent` together.

| Harness | Effort flag | Source | low | medium | high |
|---|---|---|---|---|---|
| Claude Code | `--effort` | `claude --help` (`low\|medium\|high\|xhigh\|max`) | `low` | `medium` | `max` |
| Codex | `-c model_reasoning_effort=…` | `codex exec --help` (`-c`/`--config`); key from Codex config | `low` | `medium` | `xhigh` |
| OpenCode | `--variant` | `opencode run --help` (e.g. `high`, `max`, `minimal`) | `minimal` | `medium` | `max` |
| Kiro | `--effort` | [Kiro CLI effort docs](https://kiro.dev/docs/cli/chat/effort/) (`low\|medium\|high\|xhigh\|max`) | `low` | `medium` | `max` |
| Cursor | (none — omitted silently) | `cursor agent --help` (no effort/thinking-level flag) | — | — | — |
| Gemini CLI | (none — omitted silently) | `gemini --help` (no effort/thinking-level flag) | — | — | — |

`SPEC_LOOP_AGENT` chooses the CLI. `SPEC_LOOP_HARNESS` chooses the
invocation convention and defaults from the agent basename. Adding a new
harness means extending this matrix, documenting the safety boundary, and
updating `loop.sh` in the same change.
harness means extending **both** matrices above (headless contract and
effort mapping), documenting the safety boundary, and updating
`spec_loop_launch_agent` in `lib.sh` (plus the harness case in `loop.sh`)
in the same change.

## Out of scope

Expand Down
71 changes: 71 additions & 0 deletions tools/spec-loop/tests/test_runner_fixtures.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ test_harness_command_construction() {
make_fake_agent "$TMPDIR_TEST/claude" "$TMPDIR_TEST/claude.log"
make_fake_agent "$TMPDIR_TEST/codex" "$TMPDIR_TEST/codex.log"

# Unset effort: argv must not gain effort-related flags.
spec_loop_launch_agent claude "$TMPDIR_TEST/claude" /repo "$TMPDIR_TEST/prompt.md" sonnet stream-json
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/claude.log" "<-p>"
Expand All @@ -102,6 +103,7 @@ test_harness_command_construction() {
assert_contains "$TMPDIR_TEST/claude.log" "<--model>"
assert_contains "$TMPDIR_TEST/claude.log" "<sonnet>"
assert_contains "$TMPDIR_TEST/claude.log" "stdin:PROMPT BODY"
assert_not_contains "$TMPDIR_TEST/claude.log" "<--effort>"

spec_loop_launch_agent codex "$TMPDIR_TEST/codex" /repo "$TMPDIR_TEST/prompt.md" gpt-5 stream-json
wait "$SPEC_LOOP_AGENT_PID"
Expand All @@ -114,6 +116,7 @@ test_harness_command_construction() {
assert_contains "$TMPDIR_TEST/codex.log" "<--json>"
assert_contains "$TMPDIR_TEST/codex.log" "<->"
assert_contains "$TMPDIR_TEST/codex.log" "stdin:PROMPT BODY"
assert_not_contains "$TMPDIR_TEST/codex.log" "<model_reasoning_effort="

make_fake_agent "$TMPDIR_TEST/kiro-cli" "$TMPDIR_TEST/kiro.log"
spec_loop_launch_agent kiro "$TMPDIR_TEST/kiro-cli" /repo "$TMPDIR_TEST/prompt.md" "" text
Expand All @@ -122,8 +125,76 @@ test_harness_command_construction() {
assert_contains "$TMPDIR_TEST/kiro.log" "<--no-interactive>"
assert_contains "$TMPDIR_TEST/kiro.log" "<PROMPT BODY>"
assert_not_contains "$TMPDIR_TEST/kiro.log" "<--model>"
assert_not_contains "$TMPDIR_TEST/kiro.log" "<--effort>"

# Set-and-mapped: framework high → each CLI's top effort value.
make_fake_agent "$TMPDIR_TEST/claude-effort" "$TMPDIR_TEST/claude-effort.log"
spec_loop_launch_agent claude "$TMPDIR_TEST/claude-effort" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/claude-effort.log" "<--effort>"
assert_contains "$TMPDIR_TEST/claude-effort.log" "<max>"

make_fake_agent "$TMPDIR_TEST/codex-effort" "$TMPDIR_TEST/codex-effort.log"
spec_loop_launch_agent codex "$TMPDIR_TEST/codex-effort" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/codex-effort.log" "<-c>"
assert_contains "$TMPDIR_TEST/codex-effort.log" "<model_reasoning_effort=xhigh>"

make_fake_agent "$TMPDIR_TEST/opencode" "$TMPDIR_TEST/opencode.log"
spec_loop_launch_agent opencode "$TMPDIR_TEST/opencode" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/opencode.log" "<run>"
assert_contains "$TMPDIR_TEST/opencode.log" "<--auto>"
assert_contains "$TMPDIR_TEST/opencode.log" "<--variant>"
assert_contains "$TMPDIR_TEST/opencode.log" "<max>"

# OpenCode low is the one non-identity low mapping (low → minimal).
make_fake_agent "$TMPDIR_TEST/opencode-low" "$TMPDIR_TEST/opencode-low.log"
spec_loop_launch_agent opencode "$TMPDIR_TEST/opencode-low" /repo "$TMPDIR_TEST/prompt.md" "" text low
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/opencode-low.log" "<--variant>"
assert_contains "$TMPDIR_TEST/opencode-low.log" "<minimal>"
assert_not_contains "$TMPDIR_TEST/opencode-low.log" "<low>"

# One medium case (identity mapping still worth pinning).
make_fake_agent "$TMPDIR_TEST/claude-medium" "$TMPDIR_TEST/claude-medium.log"
spec_loop_launch_agent claude "$TMPDIR_TEST/claude-medium" /repo "$TMPDIR_TEST/prompt.md" "" text medium
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/claude-medium.log" "<--effort>"
assert_contains "$TMPDIR_TEST/claude-medium.log" "<medium>"

make_fake_agent "$TMPDIR_TEST/kiro-effort" "$TMPDIR_TEST/kiro-effort.log"
spec_loop_launch_agent kiro "$TMPDIR_TEST/kiro-effort" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/kiro-effort.log" "<--effort>"
assert_contains "$TMPDIR_TEST/kiro-effort.log" "<max>"
assert_not_contains "$TMPDIR_TEST/kiro-effort.log" "<--model>"

# Set-but-unsupported: Cursor/Gemini have no effort knob — omit silently.
make_fake_agent "$TMPDIR_TEST/cursor-agent" "$TMPDIR_TEST/cursor.log"
spec_loop_launch_agent cursor "$TMPDIR_TEST/cursor-agent" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/cursor.log" "<--print>"
assert_contains "$TMPDIR_TEST/cursor.log" "<--force>"
assert_contains "$TMPDIR_TEST/cursor.log" "<--trust>"
assert_not_contains "$TMPDIR_TEST/cursor.log" "<--effort>"
assert_not_contains "$TMPDIR_TEST/cursor.log" "<--variant>"
assert_not_contains "$TMPDIR_TEST/cursor.log" "model_reasoning_effort"

make_fake_agent "$TMPDIR_TEST/gemini" "$TMPDIR_TEST/gemini.log"
spec_loop_launch_agent gemini "$TMPDIR_TEST/gemini" /repo "$TMPDIR_TEST/prompt.md" "" text high
wait "$SPEC_LOOP_AGENT_PID"
assert_contains "$TMPDIR_TEST/gemini.log" "<--yolo>"
assert_contains "$TMPDIR_TEST/gemini.log" "<--prompt>"
assert_not_contains "$TMPDIR_TEST/gemini.log" "<--effort>"
assert_not_contains "$TMPDIR_TEST/gemini.log" "<--variant>"
assert_not_contains "$TMPDIR_TEST/gemini.log" "model_reasoning_effort"
}

# loop.sh validates SPEC_LOOP_EFFORT at startup (reject anything other than
# low|medium|high|empty). That gate is not exercised here — these fixtures
# only call lib.sh helpers — so keep a manual check in the PR test plan.

test_last_sync_marker_helpers() {
local marker="$TMPDIR_TEST/.last-sync"
spec_loop_write_last_sync_marker "$marker" "abcdef1234567890"
Expand Down
16 changes: 8 additions & 8 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.