diff --git a/docs/adapters/add-a-harness.md b/docs/adapters/add-a-harness.md index 87af5ac0c..554a4e13f 100644 --- a/docs/adapters/add-a-harness.md +++ b/docs/adapters/add-a-harness.md @@ -163,6 +163,11 @@ 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. @@ -170,11 +175,22 @@ Example template: ```bash elif [ "$harness" = "" ]; then + local effort_args=() + case "$effort" in + low) effort_args=(-- ) ;; + medium) effort_args=(-- ) ;; + high) effort_args=(-- ) ;; + esac "$agent" \ ${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 diff --git a/tools/spec-loop/README.md b/tools/spec-loop/README.md index 12ce9c3a0..9d7fbb785 100644 --- a/tools/spec-loop/README.md +++ b/tools/spec-loop/README.md @@ -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 diff --git a/tools/spec-loop/lib.sh b/tools/spec-loop/lib.sh index eaa95299a..4e5029115 100644 --- a/tools/spec-loop/lib.sh +++ b/tools/spec-loop/lib.sh @@ -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) @@ -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[@]}"} \ @@ -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=$! } diff --git a/tools/spec-loop/loop.sh b/tools/spec-loop/loop.sh index fb5da88a5..7fb8d8fc7 100755 --- a/tools/spec-loop/loop.sh +++ b/tools/spec-loop/loop.sh @@ -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) @@ -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 @@ -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." @@ -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" diff --git a/tools/spec-loop/specs/spec-loop-runner.md b/tools/spec-loop/specs/spec-loop-runner.md index 197deb33b..c33db73a7 100644 --- a/tools/spec-loop/specs/spec-loop-runner.md +++ b/tools/spec-loop/specs/spec-loop-runner.md @@ -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 diff --git a/tools/spec-loop/tests/test_runner_fixtures.sh b/tools/spec-loop/tests/test_runner_fixtures.sh index a647500a4..5bdd33cb1 100755 --- a/tools/spec-loop/tests/test_runner_fixtures.sh +++ b/tools/spec-loop/tests/test_runner_fixtures.sh @@ -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>" @@ -102,6 +103,7 @@ test_harness_command_construction() { assert_contains "$TMPDIR_TEST/claude.log" "<--model>" assert_contains "$TMPDIR_TEST/claude.log" "" 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" @@ -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" "" assert_contains "$TMPDIR_TEST/kiro.log" "" 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" "" + + 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" "" + + 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" "" + assert_contains "$TMPDIR_TEST/opencode.log" "<--auto>" + assert_contains "$TMPDIR_TEST/opencode.log" "<--variant>" + assert_contains "$TMPDIR_TEST/opencode.log" "" + + # 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" "" + assert_not_contains "$TMPDIR_TEST/opencode-low.log" "" + + # 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" "" + + 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" "" + 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" diff --git a/uv.lock b/uv.lock index b70568a41..f0fe72468 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -495,7 +495,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -945,7 +945,7 @@ provides-extras = ["mcp"] [package.metadata.requires-dev] dev = [ { name = "mcp", specifier = ">=1.28.1" }, - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -1359,7 +1359,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -1570,7 +1570,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -1612,7 +1612,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -1633,7 +1633,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ] @@ -1843,7 +1843,7 @@ dev = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = ">=2.1.0" }, + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, ]