steerdev 1.1.29__tar.gz → 1.1.43__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 (129) hide show
  1. {steerdev-1.1.29 → steerdev-1.1.43}/PKG-INFO +1 -1
  2. {steerdev-1.1.29 → steerdev-1.1.43}/pyproject.toml +1 -1
  3. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/cli.py +14 -0
  4. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/prompt/builder.py +4 -0
  5. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/runner.py +18 -0
  6. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +38 -0
  7. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workspace/preparation.py +218 -30
  8. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/worktree.py +11 -1
  9. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_preparation.py +553 -0
  10. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_runner_merge_modes.py +25 -0
  11. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_runner_worktrees.py +25 -1
  12. {steerdev-1.1.29 → steerdev-1.1.43}/.github/workflows/pre-commit.yml +0 -0
  13. {steerdev-1.1.29 → steerdev-1.1.43}/.github/workflows/publish.yml +0 -0
  14. {steerdev-1.1.29 → steerdev-1.1.43}/.gitignore +0 -0
  15. {steerdev-1.1.29 → steerdev-1.1.43}/.pre-commit-config.yaml +0 -0
  16. {steerdev-1.1.29 → steerdev-1.1.43}/AGENTS.md +0 -0
  17. {steerdev-1.1.29 → steerdev-1.1.43}/CLAUDE.md +0 -0
  18. {steerdev-1.1.29 → steerdev-1.1.43}/README.md +0 -0
  19. {steerdev-1.1.29 → steerdev-1.1.43}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  20. {steerdev-1.1.29 → steerdev-1.1.43}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  21. {steerdev-1.1.29 → steerdev-1.1.43}/scripts/pre-commit-version-bump.sh +0 -0
  22. {steerdev-1.1.29 → steerdev-1.1.43}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  23. {steerdev-1.1.29 → steerdev-1.1.43}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  24. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/__init__.py +0 -0
  25. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/agent_loop.py +0 -0
  26. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/__init__.py +0 -0
  27. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/activity.py +0 -0
  28. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/agents.py +0 -0
  29. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/canals.py +0 -0
  30. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/client.py +0 -0
  31. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/commands.py +0 -0
  32. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/configs.py +0 -0
  33. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/context.py +0 -0
  34. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/events.py +0 -0
  35. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/hooks.py +0 -0
  36. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/implementation_plan.py +0 -0
  37. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/merger.py +0 -0
  38. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/messages.py +0 -0
  39. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/prd.py +0 -0
  40. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/reports.py +0 -0
  41. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/runs.py +0 -0
  42. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/sessions.py +0 -0
  43. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/specs.py +0 -0
  44. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/tasks.py +0 -0
  45. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/workflow_runs.py +0 -0
  46. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/api/workflows.py +0 -0
  47. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/config/__init__.py +0 -0
  48. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/config/models.py +0 -0
  49. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/config/platform.py +0 -0
  50. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/config/settings.py +0 -0
  51. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/evidence.py +0 -0
  52. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/evidence_assets.py +0 -0
  53. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/executor/__init__.py +0 -0
  54. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/executor/base.py +0 -0
  55. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/executor/claude.py +0 -0
  56. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/executor/stream.py +0 -0
  57. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/git/__init__.py +0 -0
  58. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/git/commit.py +0 -0
  59. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/git_ops.py +0 -0
  60. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/handlers/__init__.py +0 -0
  61. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/handlers/prd.py +0 -0
  62. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/integration.py +0 -0
  63. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/prompt/__init__.py +0 -0
  64. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/prompt/templates.py +0 -0
  65. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  66. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/py.typed +0 -0
  67. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/retry.py +0 -0
  68. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/__init__.py +0 -0
  69. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/claude_setup.py +0 -0
  70. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/repo_setup.py +0 -0
  71. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  72. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  73. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/settings.json +0 -0
  74. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  75. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  76. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  77. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  78. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  79. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  80. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  81. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  82. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  83. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  84. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  85. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/update_check.py +0 -0
  86. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/version.py +0 -0
  87. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workflow/__init__.py +0 -0
  88. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workflow/context.py +0 -0
  89. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workflow/executor.py +0 -0
  90. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workflow/memory.py +0 -0
  91. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workspace/__init__.py +0 -0
  92. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workspace/project_manager.py +0 -0
  93. {steerdev-1.1.29 → steerdev-1.1.43}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  94. {steerdev-1.1.29 → steerdev-1.1.43}/tests/__init__.py +0 -0
  95. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_agent_loop.py +0 -0
  96. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_agent_loop_extended.py +0 -0
  97. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_agents_api.py +0 -0
  98. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_api_client.py +0 -0
  99. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_claude_executor.py +0 -0
  100. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_claude_setup.py +0 -0
  101. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_client_methods.py +0 -0
  102. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_commands_api.py +0 -0
  103. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_config.py +0 -0
  104. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_config_extended.py +0 -0
  105. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_conflict_mitigation.py +0 -0
  106. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_context_search.py +0 -0
  107. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_executor.py +0 -0
  108. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_git_commit.py +0 -0
  109. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_git_ops.py +0 -0
  110. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_hooks_post_tool_use.py +0 -0
  111. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_platform_config.py +0 -0
  112. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_preparation_provenance.py +0 -0
  113. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_prompt.py +0 -0
  114. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_reports_client.py +0 -0
  115. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_resolve_max_tasks.py +0 -0
  116. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_retry.py +0 -0
  117. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_runner_boundary_commit.py +0 -0
  118. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_stream_parser.py +0 -0
  119. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_tasks.py +0 -0
  120. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_tasks_update_branch.py +0 -0
  121. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_version.py +0 -0
  122. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_wave_scoped_fetch.py +0 -0
  123. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workflow_context.py +0 -0
  124. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workflow_memory.py +0 -0
  125. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workflow_prompt_template.py +0 -0
  126. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workflow_runs_api.py +0 -0
  127. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workspace.py +0 -0
  128. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_workspace_extended.py +0 -0
  129. {steerdev-1.1.29 → steerdev-1.1.43}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.29
3
+ Version: 1.1.43
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.1.29"
3
+ version = "1.1.43"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1806,6 +1806,16 @@ def run(
1806
1806
  ),
1807
1807
  ),
1808
1808
  ] = None,
1809
+ base_branch: Annotated[
1810
+ str | None,
1811
+ typer.Option(
1812
+ "--base-branch",
1813
+ help=(
1814
+ "Branch to cut a new wave/task branch from (overrides the "
1815
+ "auto-chosen predecessor/default). Falls back to STEERDEV_BASE_BRANCH."
1816
+ ),
1817
+ ),
1818
+ ] = None,
1809
1819
  ) -> None:
1810
1820
  """Run the agent for a project!
1811
1821
 
@@ -1841,6 +1851,9 @@ def run(
1841
1851
  console.print("[red]Error: --project-id required (or set via env/config)[/red]")
1842
1852
  raise typer.Exit(1)
1843
1853
 
1854
+ # Resolve base branch override: CLI > env > None
1855
+ resolved_base_branch = base_branch or os.environ.get("STEERDEV_BASE_BRANCH") or None
1856
+
1844
1857
  # Resolve other options: CLI > config > hardcoded default
1845
1858
  resolved_model = model if model is not None else config.agent.model
1846
1859
  resolved_max_turns = max_turns if max_turns is not None else config.agent.max_turns
@@ -1937,6 +1950,7 @@ def run(
1937
1950
  task_id=task_id,
1938
1951
  agent_id=agent_id,
1939
1952
  wave_id=wave_id,
1953
+ base_branch_override=resolved_base_branch,
1940
1954
  )
1941
1955
  )
1942
1956
 
@@ -28,6 +28,10 @@ class WaveContext(BaseModel):
28
28
  wave_description: str = Field(default="", description="Description of this wave")
29
29
  wave_tasks_summary: str = Field(default="", description="Summary of tasks in this wave")
30
30
  completed_waves_summary: str = Field(default="", description="Summary of completed waves")
31
+ predecessor_branch: str | None = Field(
32
+ default=None,
33
+ description="Branch of the previous completed wave to stack this wave on",
34
+ )
31
35
 
32
36
 
33
37
  class TaskContext(BaseModel):
@@ -110,6 +110,15 @@ def build_wave_context(wave_response: dict[str, Any]) -> WaveContext | None:
110
110
  ]
111
111
  completed_summary = "\n".join(completed_lines) if completed_lines else ""
112
112
 
113
+ # Stacked-wave base: the server sends the previous completed wave's branch
114
+ # NAME (never a number — the wave name IS the branch). A blank/absent name
115
+ # means no valid predecessor, so we leave the base unset and cut from default.
116
+ predecessor_branch: str | None = None
117
+ predecessor = wave_info.get("predecessor_wave")
118
+ if predecessor:
119
+ name = (predecessor.get("name") or "").strip()
120
+ predecessor_branch = name or None
121
+
113
122
  return WaveContext(
114
123
  wave_number=wave_info.get("wave_number", 1),
115
124
  total_waves=wave_info.get("total_waves", 1),
@@ -117,6 +126,7 @@ def build_wave_context(wave_response: dict[str, Any]) -> WaveContext | None:
117
126
  wave_description=wave_info.get("description", ""),
118
127
  wave_tasks_summary=tasks_summary,
119
128
  completed_waves_summary=completed_summary,
129
+ predecessor_branch=predecessor_branch,
120
130
  )
121
131
 
122
132
 
@@ -328,6 +338,7 @@ class Runner:
328
338
  agent_id: str | None = None,
329
339
  task_id: str | None = None,
330
340
  wave_id: str | None = None,
341
+ base_branch_override: str | None = None,
331
342
  ) -> None:
332
343
  """Initialize the runner.
333
344
 
@@ -353,6 +364,8 @@ class Runner:
353
364
  task_id: Run a single specific task (bypasses queue/wave scheduling).
354
365
  wave_id: Scope scheduling to a single wave; the loop processes every
355
366
  task of that wave and stops when it is exhausted.
367
+ base_branch_override: Override the base branch used when preparing
368
+ each task's workspace.
356
369
  """
357
370
  self.project_id = project_id
358
371
  self.working_directory = Path(working_directory or Path.cwd())
@@ -380,6 +393,7 @@ class Runner:
380
393
  self._agent_id = agent_id
381
394
  self._target_task_id = task_id
382
395
  self._target_wave_id = wave_id
396
+ self._base_branch_override = base_branch_override
383
397
 
384
398
  # State
385
399
  self._state = RunState.STOPPED
@@ -826,6 +840,7 @@ class Runner:
826
840
  working_directory=self.working_directory,
827
841
  worktree_config=self._worktree_config,
828
842
  branch_config=self._branch_config,
843
+ base_branch_override=self._base_branch_override,
829
844
  )
830
845
  prep_result = await preparation.prepare(task, wave_context)
831
846
  if not prep_result.success:
@@ -1043,6 +1058,7 @@ class Runner:
1043
1058
  working_directory=self.working_directory,
1044
1059
  worktree_config=self._worktree_config,
1045
1060
  branch_config=self._branch_config,
1061
+ base_branch_override=self._base_branch_override,
1046
1062
  )
1047
1063
  prep_result = await preparation.prepare(task, wave_context)
1048
1064
  if not prep_result.success:
@@ -1500,6 +1516,7 @@ async def run_agent(
1500
1516
  task_id: str | None = None,
1501
1517
  agent_id: str | None = None,
1502
1518
  wave_id: str | None = None,
1519
+ base_branch_override: str | None = None,
1503
1520
  ) -> dict[str, Any]:
1504
1521
  """Run the steerdev agent.
1505
1522
 
@@ -1550,6 +1567,7 @@ async def run_agent(
1550
1567
  task_id=task_id,
1551
1568
  agent_id=agent_id,
1552
1569
  wave_id=wave_id,
1570
+ base_branch_override=base_branch_override,
1553
1571
  )
1554
1572
 
1555
1573
  # Install signal handler that kills the subprocess immediately on Ctrl+C
@@ -63,12 +63,34 @@ git merge-tree --write-tree origin/<default-branch> HEAD
63
63
 
64
64
  **Fallback** (if `git merge-tree` is unavailable): Skip the dry-run and rebase directly. If rebase fails, abort and re-queue as above.
65
65
 
66
+ ### Before pushing: reconcile concurrent work on the wave branch
67
+
68
+ Another task of this wave may have pushed to the wave branch while you worked.
69
+ Before any push to the wave branch:
70
+
71
+ ```bash
72
+ git fetch origin "$(git branch --show-current)"
73
+ git rebase "origin/$(git branch --show-current)"
74
+ ```
75
+
76
+ - If the rebase is clean, continue.
77
+ - If it conflicts, resolve the conflicts in the files in your working tree
78
+ (you have full context for the change you just made), then
79
+ `git rebase --continue`.
80
+ - If you genuinely cannot resolve them, abort (`git rebase --abort`) and report
81
+ a blocker with `steerdev activity report`.
82
+
66
83
  4. Create one pull request for the wave:
67
84
 
68
85
  ```bash
69
86
  steerdev git pr --title "Wave <N>: <description>" --body "..."
70
87
  ```
71
88
 
89
+ **Do not pass `--base`.** `steerdev git pr` sets the PR base automatically from
90
+ the task's recorded base branch. For a stacked wave (a wave that builds on a
91
+ previous wave of the same PRD) the base is the predecessor wave's branch, so the
92
+ PR shows only this wave's diff. Overriding `--base` would break the stack.
93
+
72
94
  5. Update all related tasks with the shared PR URL
73
95
  6. Log progress and completion in the root `docs/` progress files
74
96
 
@@ -86,11 +108,27 @@ message so GitHub integrations can link commits to issues:
86
108
  It also pushes the wave branch after each commit. Your job is to implement and,
87
109
  once the wave is done, rebase and open the PR (steps 3–5).
88
110
 
111
+ ## Stacked waves (same PRD)
112
+
113
+ Waves of one PRD are **stacked**: wave-2 is branched off wave-1, wave-3 off
114
+ wave-2, and so on. The CLI handles this for you — it cuts the wave branch off the
115
+ previous wave's branch and records that branch as the PR base. As a result:
116
+
117
+ - Each wave still gets its own PR, but its diff shows only that wave's changes.
118
+ - The PRs form a chain: `wave-1 → default`, `wave-2 → wave-1`, `wave-3 → wave-2`.
119
+ - **Do not delete a wave's branch until the next wave's PR has merged.** When
120
+ wave-1's PR merges into the default branch, GitHub automatically retargets
121
+ wave-2's PR — but only if wave-1's branch still exists.
122
+ - The first wave of a PRD (and any wave with no completed same-PRD predecessor)
123
+ is cut from the default branch as usual.
124
+
89
125
  ## Conventions
90
126
 
91
127
  - Use one branch per wave: `wave/<wave-number>`
92
128
  - Do not commit task work — the runtime commits and pushes each task automatically
93
129
  - Do not create per-task PRs inside the wave
130
+ - Do not pass `--base` to `steerdev git pr` — the base is set automatically
131
+ - Do not delete wave branches until the dependent wave's PR has merged
94
132
  - Include the shared PR URL in every related task result
95
133
  - Replace `<default-branch>` in commands above with the actual branch name from the Git Branch Configuration section
96
134
 
@@ -114,10 +114,12 @@ class WorkspacePreparation:
114
114
  working_directory: Path,
115
115
  worktree_config: WorktreeConfig,
116
116
  branch_config: BranchConfig,
117
+ base_branch_override: str | None = None,
117
118
  ) -> None:
118
119
  self.working_directory = working_directory
119
120
  self._worktree_config = worktree_config
120
121
  self._branch_config = branch_config
122
+ self._base_branch_override = base_branch_override
121
123
 
122
124
  async def prepare(
123
125
  self,
@@ -294,6 +296,48 @@ class WorkspacePreparation:
294
296
  repo_directories=prepared_repos,
295
297
  )
296
298
 
299
+ async def _claim_or_adopt(self, cwd: Path, target_branch: str) -> tuple[bool, bool]:
300
+ """Push the freshly-cut branch to claim it (plain push, no force).
301
+
302
+ Returns (success, adopted). A push rejected because the branch already
303
+ exists on origin (another VM won the race) is adopted: we fetch and
304
+ reset to the remote branch and continue its commits. Any OTHER push
305
+ failure (network, auth, ...) is a hard error, not a lost race — we do
306
+ not adopt, so the caller surfaces a real failure rather than silently
307
+ building on an unexpected branch. adopted=True means continuation
308
+ (don't persist base).
309
+ """
310
+ rc, out, err = await _run_git("push", "origin", target_branch, cwd=cwd, check=False)
311
+ if rc == 0:
312
+ logger.info(f"Claimed branch {target_branch} on origin")
313
+ return True, False
314
+
315
+ # Only a ref-already-exists / non-fast-forward rejection means we lost
316
+ # the race. Match git's rejection wording; treat anything else as fatal.
317
+ message = f"{out}\n{err}".lower()
318
+ is_lost_race = (
319
+ "[rejected]" in message
320
+ or "fetch first" in message
321
+ or "already exists" in message
322
+ or "non-fast-forward" in message
323
+ )
324
+ if not is_lost_race:
325
+ logger.warning(f"Claim push for {target_branch} failed (not a rejection): {err or out}")
326
+ return False, False
327
+
328
+ logger.info(
329
+ f"Claim of {target_branch} rejected ({err or out}); adopting existing "
330
+ f"origin/{target_branch}"
331
+ )
332
+ rc_fetch, _, _ = await _run_git("fetch", "origin", target_branch, cwd=cwd, check=False)
333
+ if rc_fetch != 0:
334
+ logger.warning(f"Failed to fetch origin/{target_branch} for adoption")
335
+ return False, True
336
+ rc, _, _ = await _run_git(
337
+ "checkout", "-B", target_branch, f"origin/{target_branch}", cwd=cwd, check=False
338
+ )
339
+ return rc == 0, True
340
+
297
341
  async def _prepare_single_repo(
298
342
  self,
299
343
  cwd: Path,
@@ -347,40 +391,180 @@ class WorkspacePreparation:
347
391
  # 4. Fetch from origin
348
392
  await _run_git("fetch", "origin", cwd=cwd, check=False)
349
393
 
350
- # 5. Sync to default branch
351
- rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
352
- if rc != 0:
353
- return PreparationResult(
354
- success=False,
355
- branch_name=target_branch,
356
- working_directory=cwd,
357
- default_branch=default_branch,
358
- is_worktree=False,
359
- error=f"Failed to checkout {default_branch}. Does the branch exist?",
360
- )
394
+ # 5. Create or checkout the target (wave) branch.
395
+ #
396
+ # Branch resolution priority — a wave has exactly ONE branch, so an
397
+ # already-existing wave branch must always be CONTINUED, never re-cut:
398
+ # a) local branch exists → checkout (same VM, mid-run)
399
+ # b) branch exists on origin → checkout tracking it (fresh clone
400
+ # / resume — continue prior commits)
401
+ # c) new wave with predecessor → stacked cut off origin/<predecessor>
402
+ # d) otherwise → default cut
403
+ # Cases (a)/(b) are continuations: they do NOT re-persist base_branch
404
+ # (task 1 already recorded the wave's base — don't clobber it). The base
405
+ # is resolved ONLY on the cut paths (c)/(d), so a continuation never
406
+ # surfaces a spurious "predecessor missing / chain broken" warning.
407
+
408
+ # Does the wave branch already exist? (origin is authoritative for a
409
+ # fresh clone). If so we CONTINUE it — no base resolution / no cut.
410
+ rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
411
+ branch_exists_local = rc == 0
361
412
 
362
- # Try fast-forward merge
363
413
  rc, _, _ = await _run_git(
364
- "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
414
+ "rev-parse", "--verify", f"origin/{target_branch}", cwd=cwd, check=False
365
415
  )
366
- if rc != 0:
367
- # Fallback: hard reset to origin
368
- logger.warning(f"Fast-forward merge failed for {default_branch}, resetting to origin")
369
- await _run_git("reset", "--hard", f"origin/{default_branch}", cwd=cwd, check=False)
370
- warnings.append(
371
- f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
372
- )
416
+ branch_exists_origin = rc == 0
373
417
 
374
- # 6. Create or checkout target branch
375
- rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
376
- branch_exists = rc == 0
418
+ is_continuation = False
419
+ effective_base = default_branch
377
420
 
378
- if branch_exists:
379
- await _run_git("checkout", target_branch, cwd=cwd)
421
+ if branch_exists_local:
422
+ # Branch already present locally (same VM, continuing the wave).
423
+ is_continuation = True
424
+ rc, _, _ = await _run_git("checkout", target_branch, cwd=cwd, check=False)
425
+ if rc != 0:
426
+ return PreparationResult(
427
+ success=False,
428
+ branch_name=target_branch,
429
+ working_directory=cwd,
430
+ default_branch=default_branch,
431
+ is_worktree=False,
432
+ error=f"Failed to checkout existing branch {target_branch}",
433
+ )
380
434
  logger.info(f"Switched to existing branch: {target_branch}")
435
+ elif branch_exists_origin:
436
+ # Wave branch exists on origin but not locally — a fresh clone / resume
437
+ # continuing a wave another VM started. Track it and continue its
438
+ # commits; NEVER re-cut (that would drop the earlier tasks' work).
439
+ is_continuation = True
440
+ rc, _, _ = await _run_git(
441
+ "checkout", "-b", target_branch, f"origin/{target_branch}", cwd=cwd, check=False
442
+ )
443
+ if rc != 0:
444
+ return PreparationResult(
445
+ success=False,
446
+ branch_name=target_branch,
447
+ working_directory=cwd,
448
+ default_branch=default_branch,
449
+ is_worktree=False,
450
+ error=f"Failed to track existing branch origin/{target_branch}",
451
+ )
452
+ logger.info(f"Continuing wave branch from origin/{target_branch}")
381
453
  else:
382
- await _run_git("checkout", "-b", target_branch, cwd=cwd)
383
- logger.info(f"Created new branch: {target_branch}")
454
+ # New branch NOW resolve the base a NEW wave branch is cut from.
455
+ # Existence is judged on ORIGIN (a sandbox is a fresh clone).
456
+ # Priority: explicit user override > predecessor wave > default
457
+ # branch. A chosen base that is missing from origin falls back to
458
+ # default with a loud warning. Warnings are emitted ONLY here, on
459
+ # the cut path — never on a continuation.
460
+ async def _origin_has(branch: str) -> bool:
461
+ rc, _, _ = await _run_git(
462
+ "rev-parse", "--verify", f"origin/{branch}", cwd=cwd, check=False
463
+ )
464
+ return rc == 0
465
+
466
+ override = self._base_branch_override
467
+ predecessor_branch = wave_context.predecessor_branch if wave_context else None
468
+
469
+ if override:
470
+ if await _origin_has(override):
471
+ effective_base = override
472
+ else:
473
+ logger.warning(
474
+ f"Chosen base branch origin/{override} missing — "
475
+ f"cutting {target_branch} from {default_branch} instead"
476
+ )
477
+ warnings.append(
478
+ f"Chosen base branch '{override}' not found on origin; "
479
+ f"cut from '{default_branch}' instead."
480
+ )
481
+ elif predecessor_branch:
482
+ if await _origin_has(predecessor_branch):
483
+ effective_base = predecessor_branch
484
+ else:
485
+ logger.warning(
486
+ f"Predecessor branch origin/{predecessor_branch} missing — "
487
+ f"stacked-wave chain broken; cutting {target_branch} from "
488
+ f"{default_branch} instead"
489
+ )
490
+ warnings.append(
491
+ f"Predecessor branch '{predecessor_branch}' not found on origin; "
492
+ f"stacked-wave chain broken — cut from '{default_branch}'. This wave "
493
+ f"may not contain the previous wave's work."
494
+ )
495
+
496
+ if effective_base != default_branch:
497
+ # Stacked cut: branch straight off the predecessor's remote branch.
498
+ # No default-branch sync — the predecessor already carries the work.
499
+ rc, _, _ = await _run_git(
500
+ "checkout",
501
+ "-b",
502
+ target_branch,
503
+ f"origin/{effective_base}",
504
+ cwd=cwd,
505
+ check=False,
506
+ )
507
+ if rc != 0:
508
+ return PreparationResult(
509
+ success=False,
510
+ branch_name=target_branch,
511
+ working_directory=cwd,
512
+ default_branch=default_branch,
513
+ is_worktree=False,
514
+ error=f"Failed to cut {target_branch} from origin/{effective_base}",
515
+ )
516
+ logger.info(f"Created {target_branch} stacked on origin/{effective_base}")
517
+ ok, adopted = await self._claim_or_adopt(cwd, target_branch)
518
+ if not ok:
519
+ return PreparationResult(
520
+ success=False,
521
+ branch_name=target_branch,
522
+ working_directory=cwd,
523
+ default_branch=default_branch,
524
+ is_worktree=False,
525
+ error=f"Failed to claim/adopt {target_branch}",
526
+ )
527
+ is_continuation = adopted
528
+ else:
529
+ # Default cut: sync to the default branch, then branch off it.
530
+ rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
531
+ if rc != 0:
532
+ return PreparationResult(
533
+ success=False,
534
+ branch_name=target_branch,
535
+ working_directory=cwd,
536
+ default_branch=default_branch,
537
+ is_worktree=False,
538
+ error=f"Failed to checkout {default_branch}. Does the branch exist?",
539
+ )
540
+
541
+ rc, _, _ = await _run_git(
542
+ "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
543
+ )
544
+ if rc != 0:
545
+ logger.warning(
546
+ f"Fast-forward merge failed for {default_branch}, resetting to origin"
547
+ )
548
+ await _run_git(
549
+ "reset", "--hard", f"origin/{default_branch}", cwd=cwd, check=False
550
+ )
551
+ warnings.append(
552
+ f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
553
+ )
554
+
555
+ await _run_git("checkout", "-b", target_branch, cwd=cwd)
556
+ logger.info(f"Created new branch: {target_branch}")
557
+ ok, adopted = await self._claim_or_adopt(cwd, target_branch)
558
+ if not ok:
559
+ return PreparationResult(
560
+ success=False,
561
+ branch_name=target_branch,
562
+ working_directory=cwd,
563
+ default_branch=default_branch,
564
+ is_worktree=False,
565
+ error=f"Failed to claim/adopt {target_branch}",
566
+ )
567
+ is_continuation = adopted
384
568
 
385
569
  # 7. Validate current branch
386
570
  _, actual_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
@@ -394,9 +578,13 @@ class WorkspacePreparation:
394
578
  error=f"Branch mismatch: expected {target_branch}, got {actual_branch}",
395
579
  )
396
580
 
397
- await persist_branch_provenance(
398
- task_id=task_id, branch=target_branch, base_branch=default_branch
399
- )
581
+ # Record provenance only when this task actually CUT the branch. A
582
+ # continuation (the branch already existed locally or on origin) must not
583
+ # overwrite the base the wave's first task recorded.
584
+ if not is_continuation:
585
+ await persist_branch_provenance(
586
+ task_id=task_id, branch=target_branch, base_branch=effective_base
587
+ )
400
588
 
401
589
  return PreparationResult(
402
590
  success=True,
@@ -206,6 +206,16 @@ class WorktrunkClient:
206
206
  return None
207
207
 
208
208
 
209
+ def wave_branch_name(name: str | None, wave_number: int) -> str:
210
+ """The single source of truth for a wave's git branch name.
211
+
212
+ A wave branch is its `name` (e.g. "wave-a1b2c3"), falling back to
213
+ "wave-{wave_number}". Used by `compute_branch_name` for the current wave and
214
+ by the runner to derive a predecessor wave's branch for stacked-wave cuts.
215
+ """
216
+ return name or f"wave-{wave_number}"
217
+
218
+
209
219
  def compute_branch_name(
210
220
  task: dict[str, Any],
211
221
  wave_context: WaveContext | None = None,
@@ -225,7 +235,7 @@ def compute_branch_name(
225
235
  Branch name string.
226
236
  """
227
237
  if wave_context:
228
- return wave_context.wave_name or f"wave-{wave_context.wave_number}"
238
+ return wave_branch_name(wave_context.wave_name, wave_context.wave_number)
229
239
 
230
240
  # Try external ticket ID first (e.g., Linear "PROJ-123")
231
241
  external_id = task.get("linear_identifier") or task.get("external_id")