steerdev 1.1.6__tar.gz → 1.1.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {steerdev-1.1.6 → steerdev-1.1.14}/.github/workflows/publish.yml +3 -0
  2. {steerdev-1.1.6 → steerdev-1.1.14}/PKG-INFO +1 -1
  3. steerdev-1.1.14/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +253 -0
  4. steerdev-1.1.14/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +130 -0
  5. {steerdev-1.1.6 → steerdev-1.1.14}/pyproject.toml +1 -1
  6. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/tasks.py +9 -0
  7. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/cli.py +23 -6
  8. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/runner.py +28 -1
  9. steerdev-1.1.14/tests/test_resolve_max_tasks.py +27 -0
  10. steerdev-1.1.14/tests/test_wave_scoped_fetch.py +60 -0
  11. {steerdev-1.1.6 → steerdev-1.1.14}/.github/workflows/pre-commit.yml +0 -0
  12. {steerdev-1.1.6 → steerdev-1.1.14}/.gitignore +0 -0
  13. {steerdev-1.1.6 → steerdev-1.1.14}/.pre-commit-config.yaml +0 -0
  14. {steerdev-1.1.6 → steerdev-1.1.14}/AGENTS.md +0 -0
  15. {steerdev-1.1.6 → steerdev-1.1.14}/CLAUDE.md +0 -0
  16. {steerdev-1.1.6 → steerdev-1.1.14}/README.md +0 -0
  17. {steerdev-1.1.6 → steerdev-1.1.14}/scripts/pre-commit-version-bump.sh +0 -0
  18. {steerdev-1.1.6 → steerdev-1.1.14}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  19. {steerdev-1.1.6 → steerdev-1.1.14}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  20. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/__init__.py +0 -0
  21. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/agent_loop.py +0 -0
  22. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/__init__.py +0 -0
  23. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/activity.py +0 -0
  24. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/agents.py +0 -0
  25. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/canals.py +0 -0
  26. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/client.py +0 -0
  27. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/commands.py +0 -0
  28. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/configs.py +0 -0
  29. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/context.py +0 -0
  30. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/events.py +0 -0
  31. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/hooks.py +0 -0
  32. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/implementation_plan.py +0 -0
  33. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/merger.py +0 -0
  34. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/messages.py +0 -0
  35. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/prd.py +0 -0
  36. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/reports.py +0 -0
  37. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/runs.py +0 -0
  38. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/sessions.py +0 -0
  39. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/specs.py +0 -0
  40. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/workflow_runs.py +0 -0
  41. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/api/workflows.py +0 -0
  42. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/config/__init__.py +0 -0
  43. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/config/models.py +0 -0
  44. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/config/platform.py +0 -0
  45. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/config/settings.py +0 -0
  46. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/evidence.py +0 -0
  47. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/evidence_assets.py +0 -0
  48. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/executor/__init__.py +0 -0
  49. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/executor/base.py +0 -0
  50. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/executor/claude.py +0 -0
  51. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/executor/stream.py +0 -0
  52. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/handlers/__init__.py +0 -0
  53. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/handlers/prd.py +0 -0
  54. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/integration.py +0 -0
  55. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/prompt/__init__.py +0 -0
  56. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/prompt/builder.py +0 -0
  57. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/prompt/templates.py +0 -0
  58. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  59. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/py.typed +0 -0
  60. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/retry.py +0 -0
  61. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/__init__.py +0 -0
  62. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/claude_setup.py +0 -0
  63. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/repo_setup.py +0 -0
  64. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  65. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  66. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/settings.json +0 -0
  67. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  68. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  69. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  70. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  71. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  72. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  73. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  74. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  75. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  76. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  77. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  78. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  79. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/update_check.py +0 -0
  80. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/version.py +0 -0
  81. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workflow/__init__.py +0 -0
  82. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workflow/context.py +0 -0
  83. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workflow/executor.py +0 -0
  84. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workflow/memory.py +0 -0
  85. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workspace/__init__.py +0 -0
  86. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workspace/preparation.py +0 -0
  87. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workspace/project_manager.py +0 -0
  88. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  89. {steerdev-1.1.6 → steerdev-1.1.14}/src/steerdev_agent/worktree.py +0 -0
  90. {steerdev-1.1.6 → steerdev-1.1.14}/tests/__init__.py +0 -0
  91. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_agent_loop.py +0 -0
  92. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_agent_loop_extended.py +0 -0
  93. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_agents_api.py +0 -0
  94. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_api_client.py +0 -0
  95. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_claude_executor.py +0 -0
  96. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_claude_setup.py +0 -0
  97. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_client_methods.py +0 -0
  98. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_commands_api.py +0 -0
  99. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_config.py +0 -0
  100. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_config_extended.py +0 -0
  101. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_conflict_mitigation.py +0 -0
  102. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_context_search.py +0 -0
  103. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_executor.py +0 -0
  104. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_platform_config.py +0 -0
  105. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_preparation.py +0 -0
  106. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_prompt.py +0 -0
  107. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_reports_client.py +0 -0
  108. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_retry.py +0 -0
  109. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_runner_merge_modes.py +0 -0
  110. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_runner_worktrees.py +0 -0
  111. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_stream_parser.py +0 -0
  112. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_tasks.py +0 -0
  113. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_version.py +0 -0
  114. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workflow_context.py +0 -0
  115. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workflow_memory.py +0 -0
  116. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workflow_prompt_template.py +0 -0
  117. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workflow_runs_api.py +0 -0
  118. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workspace.py +0 -0
  119. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_workspace_extended.py +0 -0
  120. {steerdev-1.1.6 → steerdev-1.1.14}/tests/test_worktree.py +0 -0
@@ -1,6 +1,9 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
+ push:
5
+ branches:
6
+ - main
4
7
  workflow_dispatch:
5
8
 
6
9
  permissions:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.6
3
+ Version: 1.1.14
4
4
  Summary: Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting
5
5
  Project-URL: Homepage, https://github.com/pentoai/steerdev-agent
6
6
  Project-URL: Repository, https://github.com/pentoai/steerdev-agent
@@ -0,0 +1,253 @@
1
+ # `--wave-id` implies unlimited `--max-tasks` — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `steerdev run --wave-id X` (with no `--max-tasks`) process the whole wave, while keeping every other `--max-tasks` case unchanged.
6
+
7
+ **Architecture:** Make the `--max-tasks` CLI option nullable (`None` = unspecified), add a pure `resolve_max_tasks(max_tasks, wave_id)` helper in `runner.py` that returns the effective int (unspecified + wave → 0/unlimited; unspecified + no wave → 1; explicit value always honored), and use it in `cli.py:run` for both the `run_agent(...)` call and the "Max Tasks" banner. `AgentRunner`/`run_agent` are unchanged (still take a concrete int, `0` = unlimited).
8
+
9
+ **Tech Stack:** Python 3.12, Typer CLI, pytest, uv. Lint/format: ruff. Type check: basedpyright. (Repo pre-commit runs ruff + basedpyright + an auto version bump.)
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md`
12
+ **Branch:** `feat/wave-id-implies-unlimited` (already created; the spec is committed there).
13
+
14
+ ---
15
+
16
+ ## File Structure
17
+
18
+ - `src/steerdev_agent/runner.py` — add pure `resolve_max_tasks(max_tasks: int | None, wave_id: str | None) -> int` next to the other pure resolvers (`resolve_merge_flow` is at ~line 139). One clear responsibility: resolve the effective max-tasks. No I/O.
19
+ - `src/steerdev_agent/cli.py` — in the `run` command: change the `--max-tasks` option to `int | None = None`, call `resolve_max_tasks` to get `resolved_max_tasks`, and use it for both the `max_tasks_display` banner (~line 1824) and the `max_tasks=` argument to `run_agent` (~line 1875).
20
+ - `tests/test_resolve_max_tasks.py` — new pure unit tests for `resolve_max_tasks`.
21
+
22
+ ---
23
+
24
+ ## Task 1: Pure `resolve_max_tasks` helper (TDD)
25
+
26
+ **Files:**
27
+ - Modify: `src/steerdev_agent/runner.py` (add the function near `resolve_merge_flow`, ~line 139)
28
+ - Test: `tests/test_resolve_max_tasks.py` (new)
29
+
30
+ - [ ] **Step 1: Write the failing test**
31
+
32
+ Create `tests/test_resolve_max_tasks.py` with exactly:
33
+
34
+ ```python
35
+ """Tests for resolve_max_tasks: --wave-id implies unlimited when --max-tasks is unset."""
36
+
37
+ from steerdev_agent.runner import resolve_max_tasks
38
+
39
+
40
+ def test_unspecified_with_wave_is_unlimited():
41
+ # Bare `steerdev run --wave-id X` → pull the whole wave (0 = unlimited).
42
+ assert resolve_max_tasks(None, "wave-1") == 0
43
+
44
+
45
+ def test_unspecified_without_wave_is_one():
46
+ # Historical single-task default is preserved.
47
+ assert resolve_max_tasks(None, None) == 1
48
+
49
+
50
+ def test_explicit_zero_is_honored_with_wave():
51
+ # The sandbox bootstrap passes --max-tasks 0 explicitly; still 0.
52
+ assert resolve_max_tasks(0, "wave-1") == 0
53
+
54
+
55
+ def test_explicit_cap_is_honored_with_wave():
56
+ # An explicit cap wins even when a wave is scoped.
57
+ assert resolve_max_tasks(2, "wave-1") == 2
58
+
59
+
60
+ def test_explicit_is_honored_without_wave():
61
+ assert resolve_max_tasks(5, None) == 5
62
+ ```
63
+
64
+ - [ ] **Step 2: Run test to verify it fails**
65
+
66
+ Run: `uv run pytest tests/test_resolve_max_tasks.py -q`
67
+ Expected: FAIL — `ImportError: cannot import name 'resolve_max_tasks' from 'steerdev_agent.runner'`.
68
+
69
+ - [ ] **Step 3: Implement the helper**
70
+
71
+ In `src/steerdev_agent/runner.py`, add this function immediately ABOVE `def resolve_merge_flow(` (around line 139), as a module-level function:
72
+
73
+ ```python
74
+ def resolve_max_tasks(max_tasks: int | None, wave_id: str | None) -> int:
75
+ """Resolve the effective --max-tasks for a run.
76
+
77
+ - An explicit value (including 0 = unlimited) always wins.
78
+ - Unspecified (None) with a wave scope → 0 (unlimited): a wave run pulls
79
+ every task of the wave, then stops. This makes a bare
80
+ `steerdev run --wave-id X` behave like the sandbox's
81
+ `steerdev run --wave-id X --max-tasks 0`.
82
+ - Unspecified (None) with no wave scope → 1 (the historical single-task
83
+ default).
84
+ """
85
+ if max_tasks is not None:
86
+ return max_tasks
87
+ return 0 if wave_id else 1
88
+ ```
89
+
90
+ - [ ] **Step 4: Run test to verify it passes**
91
+
92
+ Run: `uv run pytest tests/test_resolve_max_tasks.py -q`
93
+ Expected: PASS (5 passed).
94
+
95
+ - [ ] **Step 5: Commit**
96
+
97
+ ```bash
98
+ git add src/steerdev_agent/runner.py tests/test_resolve_max_tasks.py
99
+ git commit -m "feat: add resolve_max_tasks (--wave-id implies unlimited)"
100
+ ```
101
+
102
+ Note: the repo pre-commit runs ruff, basedpyright, and an auto version bump. If ruff reformats, re-add and re-commit. If basedpyright complains about the new function, fix the typing (signature is fully annotated, so it should pass). The version-bump hook modifying a version file is expected — let it include that change.
103
+
104
+ ---
105
+
106
+ ## Task 2: Wire `resolve_max_tasks` into the `run` CLI command
107
+
108
+ **Files:**
109
+ - Modify: `src/steerdev_agent/cli.py` (the `run` command — the `max_tasks` option definition ~line 1646-1652; the `max_tasks_display` line ~1824; the `run_agent(... max_tasks=max_tasks ...)` call ~1875)
110
+
111
+ This task has no new unit test of its own (the logic is the Task 1 helper; the CLI wiring is thin glue). It is verified by a manual `--help`/dry-run check and the full suite.
112
+
113
+ - [ ] **Step 1: Make the `--max-tasks` option nullable**
114
+
115
+ In `src/steerdev_agent/cli.py`, find the `max_tasks` parameter in the `run` command (currently):
116
+
117
+ ```python
118
+ max_tasks: Annotated[
119
+ int,
120
+ typer.Option(
121
+ "--max-tasks",
122
+ help="Maximum number of tasks to process (default: 1, use 0 for unlimited)",
123
+ ),
124
+ ] = 1,
125
+ ```
126
+
127
+ Replace it with:
128
+
129
+ ```python
130
+ max_tasks: Annotated[
131
+ int | None,
132
+ typer.Option(
133
+ "--max-tasks",
134
+ help=(
135
+ "Maximum number of tasks to process (default: 1, or unlimited "
136
+ "when --wave-id is set; use 0 for unlimited)"
137
+ ),
138
+ ),
139
+ ] = None,
140
+ ```
141
+
142
+ - [ ] **Step 2: Import and call `resolve_max_tasks`**
143
+
144
+ In `src/steerdev_agent/cli.py`, the `run` command already imports from the runner at the top of its body:
145
+ `from steerdev_agent.runner import run_agent`.
146
+ Change that import to also bring in the helper:
147
+
148
+ ```python
149
+ from steerdev_agent.runner import resolve_max_tasks, run_agent
150
+ ```
151
+
152
+ Then, BEFORE the `console.print(Panel(...))` banner block (i.e. before the `max_tasks_display` line ~1824), add:
153
+
154
+ ```python
155
+ resolved_max_tasks = resolve_max_tasks(max_tasks, wave_id)
156
+ ```
157
+
158
+ - [ ] **Step 3: Use `resolved_max_tasks` in the banner**
159
+
160
+ Change the display line (~1824) from:
161
+
162
+ ```python
163
+ max_tasks_display = "unlimited" if max_tasks == 0 else str(max_tasks)
164
+ ```
165
+
166
+ to:
167
+
168
+ ```python
169
+ max_tasks_display = "unlimited" if resolved_max_tasks == 0 else str(resolved_max_tasks)
170
+ ```
171
+
172
+ - [ ] **Step 4: Use `resolved_max_tasks` in the run_agent call**
173
+
174
+ In the `run_agent(...)` call (~line 1875), change:
175
+
176
+ ```python
177
+ max_tasks=max_tasks,
178
+ ```
179
+
180
+ to:
181
+
182
+ ```python
183
+ max_tasks=resolved_max_tasks,
184
+ ```
185
+
186
+ - [ ] **Step 5: Verify the CLI resolves correctly (dry run + help)**
187
+
188
+ Run these and read the "Max Tasks" line in the Starting panel (use `--dry` so nothing executes; `--project-id x` satisfies the required arg):
189
+
190
+ ```bash
191
+ uv run steerdev run --project-id x --wave-id w1 --dry 2>&1 | grep -i "max tasks"
192
+ ```
193
+ Expected: `Max Tasks: unlimited`
194
+
195
+ ```bash
196
+ uv run steerdev run --project-id x --dry 2>&1 | grep -i "max tasks"
197
+ ```
198
+ Expected: `Max Tasks: 1`
199
+
200
+ ```bash
201
+ uv run steerdev run --project-id x --wave-id w1 --max-tasks 2 --dry 2>&1 | grep -i "max tasks"
202
+ ```
203
+ Expected: `Max Tasks: 2`
204
+
205
+ If `--dry` exits before printing the panel or requires more args, instead run `uv run steerdev run --help` to confirm the `--max-tasks` help text now mentions "unlimited when --wave-id is set", and rely on Task 1's unit tests + the full suite for the resolution logic. Report which verification path you used.
206
+
207
+ - [ ] **Step 6: Run the full suite + type check**
208
+
209
+ Run: `uv run pytest -q`
210
+ Expected: all pass (the new 5 tests included; no regressions).
211
+
212
+ Run: `uv run basedpyright src/steerdev_agent/cli.py src/steerdev_agent/runner.py`
213
+ Expected: no new errors. (`max_tasks` is now `int | None`; `resolved_max_tasks` is `int`, matching `run_agent`'s `max_tasks: int` param — so no type error at the call site.)
214
+
215
+ - [ ] **Step 7: Commit**
216
+
217
+ ```bash
218
+ git add src/steerdev_agent/cli.py
219
+ git commit -m "feat: steerdev run --wave-id pulls the whole wave by default"
220
+ ```
221
+
222
+ (Pre-commit ruff/basedpyright/version-bump as in Task 1.)
223
+
224
+ ---
225
+
226
+ ## Task 3: Full verification
227
+
228
+ **Files:** none (verification only)
229
+
230
+ - [ ] **Step 1: Full test suite**
231
+
232
+ Run: `uv run pytest -q`
233
+ Expected: all pass.
234
+
235
+ - [ ] **Step 2: Lint + format check**
236
+
237
+ Run: `uv run ruff check . && uv run ruff format --check .`
238
+ Expected: clean. If format check fails, run `uv run ruff format .`, then `git add -A && git commit -m "chore: ruff format"`.
239
+
240
+ - [ ] **Step 3: Type check (whole package)**
241
+
242
+ Run: `uv run basedpyright`
243
+ Expected: no NEW errors introduced by this change. (Pre-existing errors elsewhere, if any, are out of scope — report them but don't fix.)
244
+
245
+ ---
246
+
247
+ ## Notes for the implementer
248
+
249
+ - **Why nullable:** Typer can't distinguish "user typed `--max-tasks 1`" from "default 1" if the default is `1`. Making it `None` lets `resolve_max_tasks` apply the wave-aware default only when the user didn't specify.
250
+ - **`run_agent` is unchanged:** it still takes `max_tasks: int` where `0` means unlimited (`runner.py`: `self.max_tasks = max_tasks if max_tasks != 0 else None`). We only change what value the CLI computes and passes.
251
+ - **Sandbox bootstrap is unaffected:** it passes `--max-tasks 0` explicitly, which `resolve_max_tasks(0, waveId)` returns as `0` — identical to today.
252
+ - **Do NOT** change `runner.py`'s loop, `run_agent`'s signature, or the server. Scope is the helper + the CLI wiring only.
253
+ - **Version-bump hook:** committing in this repo auto-bumps a version file via pre-commit. That extra change landing in your commit is expected; don't fight it.
@@ -0,0 +1,130 @@
1
+ # `steerdev run --wave-id` should pull the whole wave (imply unlimited max-tasks)
2
+
3
+ **Date:** 2026-06-05
4
+ **Status:** Design — awaiting review
5
+ **Scope:** `steerdev-agent` CLI only (`cli.py` option resolution). No server change.
6
+
7
+ ## Problem
8
+
9
+ `steerdev run --wave-id <id>` is meant to "process every task of a specific wave,
10
+ then stop" (its own `--wave-id` help text). But `--max-tasks` defaults to `1`, and
11
+ the run loop is governed solely by `max_tasks` (`runner.py`: `tasks_remaining =
12
+ self.max_tasks`; `0 → None → unlimited`). So a bare `steerdev run --wave-id X`
13
+ runs exactly **one** task and exits — contradicting the help text and surprising
14
+ the user.
15
+
16
+ Today the only way to get whole-wave behavior is `steerdev run --wave-id X
17
+ --max-tasks 0`, which the sandbox bootstrap passes explicitly. A human running it
18
+ locally must remember `--max-tasks 0`.
19
+
20
+ ## Goal
21
+
22
+ `steerdev run --wave-id X` (no `--max-tasks`) pulls **the whole wave** — the run
23
+ process requests the wave's next task from `/api/v1/tasks/next?wave_id=…` after
24
+ each one, until the wave is exhausted (404 → stop). This is the SAME behavior as
25
+ the sandbox; local and sandbox `run --wave-id` are identical. An explicit
26
+ `--max-tasks N` still caps the run if the user wants.
27
+
28
+ ## Key context: one shared behavior, local or sandbox
29
+
30
+ `steerdev run --wave-id` is a single code path. The run process is the puller:
31
+ it loops `get_next_wave(wave_id=…)` and executes each task. The only difference
32
+ between environments is who starts it and whether anything is pre-seeded:
33
+
34
+ - **Sandbox:** the dispatch seeds the first task as a Run row (for tracking), then
35
+ the VM runs `steerdev run --wave-id --max-tasks 0` which pulls the rest.
36
+ - **Local CLI:** nothing is dispatched; the human runs `steerdev run --wave-id X`
37
+ and it starts pulling from the first task immediately.
38
+
39
+ Standalone (no `--agent-id`) works: the server's no-agent path
40
+ (`getNextWaveTask(projectId, waveId)`) returns the wave's next task without
41
+ conflict-claiming, and the runtime marks each task `started` itself, so the
42
+ state-ordered selection advances correctly on each pull.
43
+
44
+ ## Design
45
+
46
+ Distinguish "user gave `--max-tasks`" from "default" by making the option
47
+ nullable, then resolve the effective value with a pure helper.
48
+
49
+ ### 1. Make `--max-tasks` nullable in `cli.py:run`
50
+
51
+ Change:
52
+ ```python
53
+ max_tasks: Annotated[int, typer.Option("--max-tasks", help="...default: 1...")] = 1,
54
+ ```
55
+ to:
56
+ ```python
57
+ max_tasks: Annotated[
58
+ int | None,
59
+ typer.Option(
60
+ "--max-tasks",
61
+ help="Maximum number of tasks to process (default: 1, or unlimited when --wave-id is set; use 0 for unlimited)",
62
+ ),
63
+ ] = None,
64
+ ```
65
+
66
+ ### 2. Pure resolution helper
67
+
68
+ In `runner.py` (next to the other pure resolvers like `resolve_merge_flow`),
69
+ add:
70
+ ```python
71
+ def resolve_max_tasks(max_tasks: int | None, wave_id: str | None) -> int:
72
+ """Resolve the effective --max-tasks.
73
+
74
+ - Explicit value (incl. 0 = unlimited) wins.
75
+ - Unspecified + a wave scope → 0 (unlimited): a wave run pulls the whole wave.
76
+ - Unspecified + no wave scope → 1 (the historical single-task default).
77
+ """
78
+ if max_tasks is not None:
79
+ return max_tasks
80
+ return 0 if wave_id else 1
81
+ ```
82
+
83
+ ### 3. Use it in `cli.py:run`
84
+
85
+ Before the `run_agent(...)` call, resolve:
86
+ ```python
87
+ resolved_max_tasks = resolve_max_tasks(max_tasks, wave_id)
88
+ ```
89
+ Pass `max_tasks=resolved_max_tasks` to `run_agent`, and use `resolved_max_tasks`
90
+ for the `max_tasks_display` panel line (so the "Max Tasks: unlimited" banner is
91
+ correct).
92
+
93
+ `run_agent`/`AgentRunner` are unchanged — they still take a concrete `int`
94
+ (`0 → unlimited` as today).
95
+
96
+ ## Behavior matrix (after change)
97
+
98
+ | Command | Effective max_tasks | Result |
99
+ |---|---|---|
100
+ | `run --wave-id X` | 0 (unlimited) | whole wave |
101
+ | `run --wave-id X --max-tasks 0` | 0 | whole wave (sandbox bootstrap; unchanged) |
102
+ | `run --wave-id X --max-tasks 2` | 2 | first 2 wave tasks then stop |
103
+ | `run` (no wave) | 1 | one task (unchanged) |
104
+ | `run --max-tasks 0` (no wave) | 0 | unlimited single-task queue drain (unchanged) |
105
+ | `run --task-id T` | (loop breaks after 1 by `_target_task_id` guard) | one task (unchanged) |
106
+
107
+ ## Tests
108
+
109
+ Pure unit tests for `resolve_max_tasks` (in `tests/`, e.g. `test_runner_merge_modes.py`
110
+ style or a new `test_resolve_max_tasks.py`):
111
+ - `resolve_max_tasks(None, "wave-1") == 0` (wave + unspecified → unlimited)
112
+ - `resolve_max_tasks(None, None) == 1` (no wave + unspecified → 1)
113
+ - `resolve_max_tasks(0, "wave-1") == 0` (explicit 0 honored)
114
+ - `resolve_max_tasks(2, "wave-1") == 2` (explicit cap honored, even with a wave)
115
+ - `resolve_max_tasks(5, None) == 5` (explicit honored, no wave)
116
+
117
+ (The CLI wiring + display are thin and covered by the helper; no full CliRunner
118
+ test required, matching the repo's pure-resolver test pattern.)
119
+
120
+ ## Out of scope
121
+
122
+ - No server change. No sandbox dispatch change (it already passes `--max-tasks 0`
123
+ explicitly, which `resolve_max_tasks` honors unchanged).
124
+ - The sandbox bootstrap can KEEP passing `--max-tasks 0` (explicit, still correct)
125
+ or later drop it (now redundant) — not required by this change.
126
+
127
+ ## Decisions to confirm in review
128
+
129
+ 1. Unspecified `--max-tasks` + `--wave-id` → unlimited (0). (The core ask.)
130
+ 2. The historical no-wave default stays `1` (unchanged single-task behavior).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.1.6"
3
+ version = "1.1.14"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -108,6 +108,7 @@ class TasksClient(SteerDevClient):
108
108
  self,
109
109
  project_id: str | None = None,
110
110
  agent_id: str | None = None,
111
+ wave_id: str | None = None,
111
112
  ) -> dict[str, Any] | None:
112
113
  """Fetch the next task with wave-aware scheduling.
113
114
 
@@ -117,6 +118,11 @@ class TasksClient(SteerDevClient):
117
118
  When agent_id is provided, the server uses conflict-aware scheduling
118
119
  to avoid assigning tasks that overlap files with in-flight tasks.
119
120
 
121
+ When wave_id is provided, scheduling is scoped to that wave: the server
122
+ returns tasks only from that wave and a 404 (→ None) once it is
123
+ exhausted, so the caller's loop stops instead of bleeding into another
124
+ wave or an unrelated task.
125
+
120
126
  Returns either:
121
127
  - A wave response dict (has "wave" and "context" keys) with full wave context
122
128
  - A plain task dict (no wave keys) when no waves exist for the project
@@ -125,6 +131,7 @@ class TasksClient(SteerDevClient):
125
131
  Args:
126
132
  project_id: Filter by project ID. Falls back to STEERDEV_PROJECT_ID env var.
127
133
  agent_id: Agent ID for conflict-aware scheduling.
134
+ wave_id: Scope scheduling to a single wave (sandbox wave dispatch).
128
135
 
129
136
  Returns:
130
137
  Wave response, plain task, or None.
@@ -136,6 +143,8 @@ class TasksClient(SteerDevClient):
136
143
  params["project_id"] = effective_project_id
137
144
  if agent_id:
138
145
  params["agent_id"] = agent_id
146
+ if wave_id:
147
+ params["wave_id"] = wave_id
139
148
 
140
149
  console.print(f"Fetching next task from {self.api_base}/tasks/next")
141
150
  response = self.get("/tasks/next", params=params)
@@ -1644,12 +1644,15 @@ def run(
1644
1644
  ),
1645
1645
  ] = None,
1646
1646
  max_tasks: Annotated[
1647
- int,
1647
+ int | None,
1648
1648
  typer.Option(
1649
1649
  "--max-tasks",
1650
- help="Maximum number of tasks to process (default: 1, use 0 for unlimited)",
1650
+ help=(
1651
+ "Maximum number of tasks to process (default: 1, or unlimited "
1652
+ "when --wave-id is set; use 0 for unlimited)"
1653
+ ),
1651
1654
  ),
1652
- ] = 1,
1655
+ ] = None,
1653
1656
  timeout: Annotated[
1654
1657
  int | None,
1655
1658
  typer.Option(
@@ -1745,6 +1748,16 @@ def run(
1745
1748
  help="Run a specific task by ID (bypasses queue/wave scheduling)",
1746
1749
  ),
1747
1750
  ] = None,
1751
+ wave_id: Annotated[
1752
+ str | None,
1753
+ typer.Option(
1754
+ "--wave-id",
1755
+ help=(
1756
+ "Process every task of a specific wave, then stop. Scopes wave "
1757
+ "scheduling to this wave (used by sandbox wave dispatch)."
1758
+ ),
1759
+ ),
1760
+ ] = None,
1748
1761
  ) -> None:
1749
1762
  """Run the agent for a project!
1750
1763
 
@@ -1766,7 +1779,7 @@ def run(
1766
1779
  steerdev run --project-id abc123 --workflow-id wf-abc123
1767
1780
  steerdev run --config custom.yaml --project-id abc123
1768
1781
  """
1769
- from steerdev_agent.runner import run_agent
1782
+ from steerdev_agent.runner import resolve_max_tasks, run_agent
1770
1783
 
1771
1784
  # Get config from context (loaded in app callback)
1772
1785
  config: SteerDevConfig = ctx.obj or SteerDevConfig()
@@ -1811,9 +1824,11 @@ def run(
1811
1824
  dry_run_status = "enabled" if dry_run else "disabled"
1812
1825
  waves_status = "enabled" if waves else "disabled"
1813
1826
  canals_status = "enabled" if canals else "disabled"
1814
- max_tasks_display = "unlimited" if max_tasks == 0 else str(max_tasks)
1827
+ resolved_max_tasks = resolve_max_tasks(max_tasks, wave_id)
1828
+ max_tasks_display = "unlimited" if resolved_max_tasks == 0 else str(resolved_max_tasks)
1815
1829
  workflow_status = resolved_workflow_id or "auto (per-task)"
1816
1830
  agent_name_display = agent_name or "(auto from env)"
1831
+ wave_scope_display = wave_id or "(all waves)"
1817
1832
 
1818
1833
  console.print(
1819
1834
  Panel(
@@ -1826,6 +1841,7 @@ def run(
1826
1841
  f"Timeout: {resolved_timeout}s\n"
1827
1842
  f"Workflow: {workflow_status}\n"
1828
1843
  f"Waves: {waves_status}\n"
1844
+ f"Wave Scope: {wave_scope_display}\n"
1829
1845
  f"Canals: {canals_status}\n"
1830
1846
  f"Worktrees: {worktree_status}\n"
1831
1847
  f"Evidence: {evidence_status}\n"
@@ -1860,7 +1876,7 @@ def run(
1860
1876
  agent_name=agent_name,
1861
1877
  model=resolved_model,
1862
1878
  max_turns=resolved_max_turns,
1863
- max_tasks=max_tasks,
1879
+ max_tasks=resolved_max_tasks,
1864
1880
  timeout_seconds=resolved_timeout,
1865
1881
  enable_waves=waves,
1866
1882
  enable_canals=canals,
@@ -1872,6 +1888,7 @@ def run(
1872
1888
  branch_config=resolved_branch_config,
1873
1889
  task_id=task_id,
1874
1890
  agent_id=agent_id,
1891
+ wave_id=wave_id,
1875
1892
  )
1876
1893
  )
1877
1894
 
@@ -136,6 +136,22 @@ def extract_task_and_wave_context(
136
136
  return wave_response, None, suggested_workflow_id
137
137
 
138
138
 
139
+ def resolve_max_tasks(max_tasks: int | None, wave_id: str | None) -> int:
140
+ """Resolve the effective --max-tasks for a run.
141
+
142
+ - An explicit value (including 0 = unlimited) always wins.
143
+ - Unspecified (None) with a wave scope → 0 (unlimited): a wave run pulls
144
+ every task of the wave, then stops. This makes a bare
145
+ `steerdev run --wave-id X` behave like the sandbox's
146
+ `steerdev run --wave-id X --max-tasks 0`.
147
+ - Unspecified (None) with no wave scope → 1 (the historical single-task
148
+ default).
149
+ """
150
+ if max_tasks is not None:
151
+ return max_tasks
152
+ return 0 if wave_id else 1
153
+
154
+
139
155
  def resolve_merge_flow(
140
156
  waves_enabled: bool,
141
157
  canals_enabled: bool,
@@ -288,6 +304,7 @@ class Runner:
288
304
  shutdown_event: asyncio.Event | None = None,
289
305
  agent_id: str | None = None,
290
306
  task_id: str | None = None,
307
+ wave_id: str | None = None,
291
308
  ) -> None:
292
309
  """Initialize the runner.
293
310
 
@@ -310,6 +327,9 @@ class Runner:
310
327
  dry_run: If True, print the command without executing it.
311
328
  retry_config: Retry configuration for failed tasks.
312
329
  agent_id: Database agent ID for conflict-aware scheduling.
330
+ task_id: Run a single specific task (bypasses queue/wave scheduling).
331
+ wave_id: Scope scheduling to a single wave; the loop processes every
332
+ task of that wave and stops when it is exhausted.
313
333
  """
314
334
  self.project_id = project_id
315
335
  self.working_directory = Path(working_directory or Path.cwd())
@@ -336,6 +356,7 @@ class Runner:
336
356
  self._enable_canals = enable_canals
337
357
  self._agent_id = agent_id
338
358
  self._target_task_id = task_id
359
+ self._target_wave_id = wave_id
339
360
 
340
361
  # State
341
362
  self._state = RunState.STOPPED
@@ -1144,6 +1165,7 @@ class Runner:
1144
1165
  client.get_next_wave(
1145
1166
  project_id=self.project_id,
1146
1167
  agent_id=self._agent_id,
1168
+ wave_id=self._target_wave_id,
1147
1169
  )
1148
1170
  )
1149
1171
  )
@@ -1153,7 +1175,10 @@ class Runner:
1153
1175
  exc_info=True,
1154
1176
  )
1155
1177
 
1156
- if not task:
1178
+ # When scoped to a wave, never fall back to single-task
1179
+ # mode — an empty wave response means the wave is done, so
1180
+ # the loop should stop rather than pull an unrelated task.
1181
+ if not task and not self._target_wave_id:
1157
1182
  task = client.get_next_task(
1158
1183
  project_id=self.project_id,
1159
1184
  agent_id=self._agent_id,
@@ -1412,6 +1437,7 @@ async def run_agent(
1412
1437
  branch_config: BranchConfig | None = None,
1413
1438
  task_id: str | None = None,
1414
1439
  agent_id: str | None = None,
1440
+ wave_id: str | None = None,
1415
1441
  ) -> dict[str, Any]:
1416
1442
  """Run the steerdev agent.
1417
1443
 
@@ -1461,6 +1487,7 @@ async def run_agent(
1461
1487
  branch_config=branch_config,
1462
1488
  task_id=task_id,
1463
1489
  agent_id=agent_id,
1490
+ wave_id=wave_id,
1464
1491
  )
1465
1492
 
1466
1493
  # Install signal handler that kills the subprocess immediately on Ctrl+C
@@ -0,0 +1,27 @@
1
+ """Tests for resolve_max_tasks: --wave-id implies unlimited when --max-tasks is unset."""
2
+
3
+ from steerdev_agent.runner import resolve_max_tasks
4
+
5
+
6
+ def test_unspecified_with_wave_is_unlimited():
7
+ # Bare `steerdev run --wave-id X` → pull the whole wave (0 = unlimited).
8
+ assert resolve_max_tasks(None, "wave-1") == 0
9
+
10
+
11
+ def test_unspecified_without_wave_is_one():
12
+ # Historical single-task default is preserved.
13
+ assert resolve_max_tasks(None, None) == 1
14
+
15
+
16
+ def test_explicit_zero_is_honored_with_wave():
17
+ # The sandbox bootstrap passes --max-tasks 0 explicitly; still 0.
18
+ assert resolve_max_tasks(0, "wave-1") == 0
19
+
20
+
21
+ def test_explicit_cap_is_honored_with_wave():
22
+ # An explicit cap wins even when a wave is scoped.
23
+ assert resolve_max_tasks(2, "wave-1") == 2
24
+
25
+
26
+ def test_explicit_is_honored_without_wave():
27
+ assert resolve_max_tasks(5, None) == 5
@@ -0,0 +1,60 @@
1
+ """Tests for wave-scoped task fetching (sandbox wave dispatch).
2
+
3
+ `steerdev run --wave-id <id>` scopes scheduling to a single wave: the client
4
+ passes `wave_id` to GET /tasks/next, and the server returns tasks only from that
5
+ wave (404 → None once exhausted) so the run loop stops instead of bleeding into
6
+ another wave or an unrelated task.
7
+ """
8
+
9
+ from typing import Any
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import httpx
13
+
14
+ from steerdev_agent.api.tasks import TasksClient
15
+
16
+
17
+ def _make_response(
18
+ status_code: int,
19
+ json_body: dict[str, Any] | None = None,
20
+ text: str = "",
21
+ ) -> MagicMock:
22
+ resp = MagicMock(spec=httpx.Response)
23
+ resp.status_code = status_code
24
+ resp.text = text or (str(json_body) if json_body else "")
25
+ if json_body is not None:
26
+ resp.json.return_value = json_body
27
+ return resp
28
+
29
+
30
+ class TestGetNextWaveScoping:
31
+ def test_wave_id_is_forwarded_as_param(self):
32
+ with TasksClient(api_key="test-key") as client:
33
+ mock_resp = _make_response(200, {"wave": {"id": "wave-1"}, "context": {}})
34
+ with patch.object(client, "get", return_value=mock_resp) as mock_get:
35
+ client.get_next_wave(
36
+ project_id="proj-1",
37
+ agent_id="agent-1",
38
+ wave_id="wave-1",
39
+ )
40
+ params = mock_get.call_args.kwargs["params"]
41
+ assert params["wave_id"] == "wave-1"
42
+ assert params["waves"] == "true"
43
+ assert params["project_id"] == "proj-1"
44
+ assert params["agent_id"] == "agent-1"
45
+
46
+ def test_wave_id_omitted_when_not_scoped(self):
47
+ with TasksClient(api_key="test-key") as client:
48
+ mock_resp = _make_response(200, {"id": "task-1"})
49
+ with patch.object(client, "get", return_value=mock_resp) as mock_get:
50
+ client.get_next_wave(project_id="proj-1")
51
+ params = mock_get.call_args.kwargs["params"]
52
+ assert "wave_id" not in params
53
+
54
+ def test_exhausted_wave_returns_none_on_404(self):
55
+ # A wave-scoped request 404s once the wave is done — the loop stops.
56
+ with TasksClient(api_key="test-key") as client:
57
+ mock_resp = _make_response(404, text="No tasks available")
58
+ with patch.object(client, "get", return_value=mock_resp):
59
+ result = client.get_next_wave(project_id="proj-1", wave_id="wave-1")
60
+ assert result is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes