steerdev 1.1.29__tar.gz → 1.1.31__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.31}/PKG-INFO +1 -1
  2. {steerdev-1.1.29 → steerdev-1.1.31}/pyproject.toml +1 -1
  3. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/prompt/builder.py +4 -0
  4. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/runner.py +10 -0
  5. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +21 -0
  6. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workspace/preparation.py +78 -25
  7. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/worktree.py +11 -1
  8. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_preparation.py +135 -0
  9. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_runner_merge_modes.py +25 -0
  10. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_runner_worktrees.py +17 -1
  11. {steerdev-1.1.29 → steerdev-1.1.31}/.github/workflows/pre-commit.yml +0 -0
  12. {steerdev-1.1.29 → steerdev-1.1.31}/.github/workflows/publish.yml +0 -0
  13. {steerdev-1.1.29 → steerdev-1.1.31}/.gitignore +0 -0
  14. {steerdev-1.1.29 → steerdev-1.1.31}/.pre-commit-config.yaml +0 -0
  15. {steerdev-1.1.29 → steerdev-1.1.31}/AGENTS.md +0 -0
  16. {steerdev-1.1.29 → steerdev-1.1.31}/CLAUDE.md +0 -0
  17. {steerdev-1.1.29 → steerdev-1.1.31}/README.md +0 -0
  18. {steerdev-1.1.29 → steerdev-1.1.31}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  19. {steerdev-1.1.29 → steerdev-1.1.31}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  20. {steerdev-1.1.29 → steerdev-1.1.31}/scripts/pre-commit-version-bump.sh +0 -0
  21. {steerdev-1.1.29 → steerdev-1.1.31}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  22. {steerdev-1.1.29 → steerdev-1.1.31}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  23. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/__init__.py +0 -0
  24. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/agent_loop.py +0 -0
  25. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/__init__.py +0 -0
  26. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/activity.py +0 -0
  27. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/agents.py +0 -0
  28. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/canals.py +0 -0
  29. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/client.py +0 -0
  30. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/commands.py +0 -0
  31. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/configs.py +0 -0
  32. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/context.py +0 -0
  33. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/events.py +0 -0
  34. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/hooks.py +0 -0
  35. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/implementation_plan.py +0 -0
  36. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/merger.py +0 -0
  37. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/messages.py +0 -0
  38. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/prd.py +0 -0
  39. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/reports.py +0 -0
  40. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/runs.py +0 -0
  41. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/sessions.py +0 -0
  42. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/specs.py +0 -0
  43. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/tasks.py +0 -0
  44. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/workflow_runs.py +0 -0
  45. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/api/workflows.py +0 -0
  46. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/cli.py +0 -0
  47. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/config/__init__.py +0 -0
  48. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/config/models.py +0 -0
  49. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/config/platform.py +0 -0
  50. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/config/settings.py +0 -0
  51. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/evidence.py +0 -0
  52. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/evidence_assets.py +0 -0
  53. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/executor/__init__.py +0 -0
  54. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/executor/base.py +0 -0
  55. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/executor/claude.py +0 -0
  56. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/executor/stream.py +0 -0
  57. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/git/__init__.py +0 -0
  58. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/git/commit.py +0 -0
  59. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/git_ops.py +0 -0
  60. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/handlers/__init__.py +0 -0
  61. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/handlers/prd.py +0 -0
  62. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/integration.py +0 -0
  63. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/prompt/__init__.py +0 -0
  64. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/prompt/templates.py +0 -0
  65. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  66. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/py.typed +0 -0
  67. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/retry.py +0 -0
  68. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/__init__.py +0 -0
  69. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/claude_setup.py +0 -0
  70. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/repo_setup.py +0 -0
  71. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  72. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  73. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/settings.json +0 -0
  74. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  75. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  76. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  77. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  78. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  79. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  80. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  81. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  82. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  83. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  84. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  85. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/update_check.py +0 -0
  86. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/version.py +0 -0
  87. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workflow/__init__.py +0 -0
  88. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workflow/context.py +0 -0
  89. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workflow/executor.py +0 -0
  90. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workflow/memory.py +0 -0
  91. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workspace/__init__.py +0 -0
  92. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workspace/project_manager.py +0 -0
  93. {steerdev-1.1.29 → steerdev-1.1.31}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  94. {steerdev-1.1.29 → steerdev-1.1.31}/tests/__init__.py +0 -0
  95. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_agent_loop.py +0 -0
  96. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_agent_loop_extended.py +0 -0
  97. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_agents_api.py +0 -0
  98. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_api_client.py +0 -0
  99. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_claude_executor.py +0 -0
  100. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_claude_setup.py +0 -0
  101. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_client_methods.py +0 -0
  102. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_commands_api.py +0 -0
  103. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_config.py +0 -0
  104. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_config_extended.py +0 -0
  105. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_conflict_mitigation.py +0 -0
  106. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_context_search.py +0 -0
  107. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_executor.py +0 -0
  108. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_git_commit.py +0 -0
  109. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_git_ops.py +0 -0
  110. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_hooks_post_tool_use.py +0 -0
  111. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_platform_config.py +0 -0
  112. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_preparation_provenance.py +0 -0
  113. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_prompt.py +0 -0
  114. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_reports_client.py +0 -0
  115. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_resolve_max_tasks.py +0 -0
  116. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_retry.py +0 -0
  117. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_runner_boundary_commit.py +0 -0
  118. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_stream_parser.py +0 -0
  119. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_tasks.py +0 -0
  120. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_tasks_update_branch.py +0 -0
  121. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_version.py +0 -0
  122. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_wave_scoped_fetch.py +0 -0
  123. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workflow_context.py +0 -0
  124. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workflow_memory.py +0 -0
  125. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workflow_prompt_template.py +0 -0
  126. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workflow_runs_api.py +0 -0
  127. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workspace.py +0 -0
  128. {steerdev-1.1.29 → steerdev-1.1.31}/tests/test_workspace_extended.py +0 -0
  129. {steerdev-1.1.29 → steerdev-1.1.31}/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.31
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.31"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
 
@@ -69,6 +69,11 @@ git merge-tree --write-tree origin/<default-branch> HEAD
69
69
  steerdev git pr --title "Wave <N>: <description>" --body "..."
70
70
  ```
71
71
 
72
+ **Do not pass `--base`.** `steerdev git pr` sets the PR base automatically from
73
+ the task's recorded base branch. For a stacked wave (a wave that builds on a
74
+ previous wave of the same PRD) the base is the predecessor wave's branch, so the
75
+ PR shows only this wave's diff. Overriding `--base` would break the stack.
76
+
72
77
  5. Update all related tasks with the shared PR URL
73
78
  6. Log progress and completion in the root `docs/` progress files
74
79
 
@@ -86,11 +91,27 @@ message so GitHub integrations can link commits to issues:
86
91
  It also pushes the wave branch after each commit. Your job is to implement and,
87
92
  once the wave is done, rebase and open the PR (steps 3–5).
88
93
 
94
+ ## Stacked waves (same PRD)
95
+
96
+ Waves of one PRD are **stacked**: wave-2 is branched off wave-1, wave-3 off
97
+ wave-2, and so on. The CLI handles this for you — it cuts the wave branch off the
98
+ previous wave's branch and records that branch as the PR base. As a result:
99
+
100
+ - Each wave still gets its own PR, but its diff shows only that wave's changes.
101
+ - The PRs form a chain: `wave-1 → default`, `wave-2 → wave-1`, `wave-3 → wave-2`.
102
+ - **Do not delete a wave's branch until the next wave's PR has merged.** When
103
+ wave-1's PR merges into the default branch, GitHub automatically retargets
104
+ wave-2's PR — but only if wave-1's branch still exists.
105
+ - The first wave of a PRD (and any wave with no completed same-PRD predecessor)
106
+ is cut from the default branch as usual.
107
+
89
108
  ## Conventions
90
109
 
91
110
  - Use one branch per wave: `wave/<wave-number>`
92
111
  - Do not commit task work — the runtime commits and pushes each task automatically
93
112
  - Do not create per-task PRs inside the wave
113
+ - Do not pass `--base` to `steerdev git pr` — the base is set automatically
114
+ - Do not delete wave branches until the dependent wave's PR has merged
94
115
  - Include the shared PR URL in every related task result
95
116
  - Replace `<default-branch>` in commands above with the actual branch name from the Git Branch Configuration section
96
117
 
@@ -347,38 +347,91 @@ class WorkspacePreparation:
347
347
  # 4. Fetch from origin
348
348
  await _run_git("fetch", "origin", cwd=cwd, check=False)
349
349
 
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
- )
361
-
362
- # Try fast-forward merge
363
- rc, _, _ = await _run_git(
364
- "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
365
- )
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}"
350
+ # 5. Resolve the base this wave stacks on. For a new wave with a
351
+ # predecessor (stacked-wave mode), branch directly off the predecessor's
352
+ # remote branch so the wave physically contains the previous wave's work.
353
+ # If that branch is missing from origin (never pushed / deleted), the
354
+ # chain is broken — fall back to the default branch and surface a loud
355
+ # warning rather than silently building on the wrong base.
356
+ predecessor_branch = wave_context.predecessor_branch if wave_context else None
357
+ effective_base = default_branch
358
+ if predecessor_branch:
359
+ rc, _, _ = await _run_git(
360
+ "rev-parse", "--verify", f"origin/{predecessor_branch}", cwd=cwd, check=False
372
361
  )
362
+ if rc == 0:
363
+ effective_base = predecessor_branch
364
+ else:
365
+ logger.warning(
366
+ f"Predecessor branch origin/{predecessor_branch} missing — "
367
+ f"stacked-wave chain broken; cutting {target_branch} from "
368
+ f"{default_branch} instead"
369
+ )
370
+ warnings.append(
371
+ f"Predecessor branch '{predecessor_branch}' not found on origin; "
372
+ f"stacked-wave chain broken — cut from '{default_branch}'. This wave "
373
+ f"may not contain the previous wave's work."
374
+ )
373
375
 
374
- # 6. Create or checkout target branch
376
+ # 6. Create or checkout the target (wave) branch.
375
377
  rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
376
378
  branch_exists = rc == 0
377
379
 
378
380
  if branch_exists:
379
- await _run_git("checkout", target_branch, cwd=cwd)
381
+ # Branch already present locally (e.g. a continuing wave on a fresh
382
+ # clone): just switch to it.
383
+ rc, _, _ = await _run_git("checkout", target_branch, cwd=cwd, check=False)
384
+ if rc != 0:
385
+ return PreparationResult(
386
+ success=False,
387
+ branch_name=target_branch,
388
+ working_directory=cwd,
389
+ default_branch=default_branch,
390
+ is_worktree=False,
391
+ error=f"Failed to checkout existing branch {target_branch}",
392
+ )
380
393
  logger.info(f"Switched to existing branch: {target_branch}")
394
+ elif effective_base != default_branch:
395
+ # Stacked cut: branch straight off the predecessor's remote branch.
396
+ # No default-branch sync — the predecessor already carries the work.
397
+ rc, _, _ = await _run_git(
398
+ "checkout", "-b", target_branch, f"origin/{effective_base}", cwd=cwd, check=False
399
+ )
400
+ if rc != 0:
401
+ return PreparationResult(
402
+ success=False,
403
+ branch_name=target_branch,
404
+ working_directory=cwd,
405
+ default_branch=default_branch,
406
+ is_worktree=False,
407
+ error=f"Failed to cut {target_branch} from origin/{effective_base}",
408
+ )
409
+ logger.info(f"Created {target_branch} stacked on origin/{effective_base}")
381
410
  else:
411
+ # Default cut: sync to the default branch, then branch off it.
412
+ rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
413
+ if rc != 0:
414
+ return PreparationResult(
415
+ success=False,
416
+ branch_name=target_branch,
417
+ working_directory=cwd,
418
+ default_branch=default_branch,
419
+ is_worktree=False,
420
+ error=f"Failed to checkout {default_branch}. Does the branch exist?",
421
+ )
422
+
423
+ rc, _, _ = await _run_git(
424
+ "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
425
+ )
426
+ if rc != 0:
427
+ logger.warning(
428
+ f"Fast-forward merge failed for {default_branch}, resetting to origin"
429
+ )
430
+ await _run_git("reset", "--hard", f"origin/{default_branch}", cwd=cwd, check=False)
431
+ warnings.append(
432
+ f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
433
+ )
434
+
382
435
  await _run_git("checkout", "-b", target_branch, cwd=cwd)
383
436
  logger.info(f"Created new branch: {target_branch}")
384
437
 
@@ -395,7 +448,7 @@ class WorkspacePreparation:
395
448
  )
396
449
 
397
450
  await persist_branch_provenance(
398
- task_id=task_id, branch=target_branch, base_branch=default_branch
451
+ task_id=task_id, branch=target_branch, base_branch=effective_base
399
452
  )
400
453
 
401
454
  return PreparationResult(
@@ -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")
@@ -368,6 +368,141 @@ class TestWorkspacePreparationInPlace:
368
368
  assert any("fast-forward" in w.lower() or "reset" in w.lower() for w in result.warnings)
369
369
 
370
370
 
371
+ class TestWorkspacePreparationStackedWave:
372
+ """Tests for stacked-wave cuts (wave-N branches off the predecessor wave)."""
373
+
374
+ @pytest.mark.asyncio
375
+ async def test_stacked_cut_branches_off_predecessor(
376
+ self, worktree_disabled, default_config, sample_task
377
+ ):
378
+ """A new wave with a predecessor branches off origin/<predecessor>."""
379
+ from steerdev_agent.prompt.builder import WaveContext
380
+
381
+ wave_ctx = WaveContext(
382
+ wave_number=2,
383
+ total_waves=3,
384
+ wave_name="wave-bbb222",
385
+ predecessor_branch="wave-aaa111",
386
+ )
387
+
388
+ prep = WorkspacePreparation(
389
+ working_directory=Path("/tmp/repo"),
390
+ worktree_config=worktree_disabled,
391
+ branch_config=default_config,
392
+ )
393
+
394
+ calls = []
395
+
396
+ async def mock_run_git(*args, cwd, check=True):
397
+ calls.append(args)
398
+ cmd = " ".join(args)
399
+ if "rev-parse --is-inside-work-tree" in cmd:
400
+ return (0, "true", "")
401
+ if "branch --show-current" in cmd:
402
+ # Start on main; after checkout -b we're on the wave branch.
403
+ if any("checkout -b" in " ".join(c) for c in calls):
404
+ return (0, "wave-bbb222", "")
405
+ return (0, "main", "")
406
+ if "status --porcelain" in cmd:
407
+ return (0, "", "")
408
+ if "fetch origin" in cmd:
409
+ return (0, "", "")
410
+ if "rev-parse --verify origin/wave-aaa111" in cmd:
411
+ return (0, "deadbeef", "") # predecessor exists on origin
412
+ if "rev-parse --verify" in cmd:
413
+ return (1, "", "") # local wave branch doesn't exist yet
414
+ if "checkout -b" in cmd:
415
+ return (0, "", "")
416
+ if "checkout" in cmd:
417
+ return (0, "", "")
418
+ return (0, "", "")
419
+
420
+ with (
421
+ patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git),
422
+ patch("steerdev_agent.workspace.preparation.persist_branch_provenance") as mock_persist,
423
+ ):
424
+ result = await prep.prepare(sample_task, wave_ctx)
425
+
426
+ assert result.success is True
427
+ assert result.branch_name == "wave-bbb222"
428
+ # The new branch is cut directly off the predecessor's remote branch.
429
+ checkout_b = [c for c in calls if "checkout -b" in " ".join(c)]
430
+ assert len(checkout_b) == 1
431
+ assert "origin/wave-aaa111" in " ".join(checkout_b[0])
432
+ # We must NOT sync to the default branch on the stacked path.
433
+ assert not any("checkout main" in " ".join(c) for c in calls)
434
+ # Provenance records the predecessor as the base so `steerdev git pr`
435
+ # targets it (stacked PR base).
436
+ mock_persist.assert_awaited_once_with(
437
+ task_id=sample_task["id"], branch="wave-bbb222", base_branch="wave-aaa111"
438
+ )
439
+
440
+ @pytest.mark.asyncio
441
+ async def test_missing_predecessor_falls_back_to_default(
442
+ self, worktree_disabled, default_config, sample_task
443
+ ):
444
+ """If origin/<predecessor> is missing, cut from default + warn (degraded)."""
445
+ from steerdev_agent.prompt.builder import WaveContext
446
+
447
+ wave_ctx = WaveContext(
448
+ wave_number=2,
449
+ total_waves=3,
450
+ wave_name="wave-bbb222",
451
+ predecessor_branch="wave-gone",
452
+ )
453
+
454
+ prep = WorkspacePreparation(
455
+ working_directory=Path("/tmp/repo"),
456
+ worktree_config=worktree_disabled,
457
+ branch_config=default_config,
458
+ )
459
+
460
+ calls = []
461
+
462
+ async def mock_run_git(*args, cwd, check=True):
463
+ calls.append(args)
464
+ cmd = " ".join(args)
465
+ if "rev-parse --is-inside-work-tree" in cmd:
466
+ return (0, "true", "")
467
+ if "branch --show-current" in cmd:
468
+ if any("checkout -b" in " ".join(c) for c in calls):
469
+ return (0, "wave-bbb222", "")
470
+ return (0, "main", "")
471
+ if "status --porcelain" in cmd:
472
+ return (0, "", "")
473
+ if "fetch origin" in cmd:
474
+ return (0, "", "")
475
+ if "rev-parse --verify origin/wave-gone" in cmd:
476
+ return (1, "", "") # predecessor NOT on origin
477
+ if "rev-parse --verify" in cmd:
478
+ return (1, "", "")
479
+ if "checkout main" in cmd:
480
+ return (0, "", "")
481
+ if "merge --ff-only" in cmd:
482
+ return (0, "", "")
483
+ if "checkout -b" in cmd:
484
+ return (0, "", "")
485
+ if "checkout" in cmd:
486
+ return (0, "", "")
487
+ return (0, "", "")
488
+
489
+ with (
490
+ patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git),
491
+ patch("steerdev_agent.workspace.preparation.persist_branch_provenance"),
492
+ ):
493
+ result = await prep.prepare(sample_task, wave_ctx)
494
+
495
+ assert result.success is True
496
+ assert result.branch_name == "wave-bbb222"
497
+ # Fell back to the default-branch sync path.
498
+ assert any("checkout main" in " ".join(c) for c in calls)
499
+ # And surfaced a warning that the chain was broken.
500
+ assert any(
501
+ "wave-gone" in w or "predecessor" in w.lower() or "chain" in w.lower()
502
+ for w in result.warnings
503
+ )
504
+
505
+
371
506
  class TestWorkspacePreparationMultiRepo:
372
507
  """Tests for multi-repo workspace preparation."""
373
508
 
@@ -2,12 +2,37 @@
2
2
 
3
3
  from steerdev_agent.prompt.builder import WaveContext
4
4
  from steerdev_agent.runner import (
5
+ build_wave_context,
5
6
  build_workflow_task_context,
6
7
  extract_task_and_wave_context,
7
8
  resolve_merge_flow,
8
9
  )
9
10
 
10
11
 
12
+ class TestPredecessorBranch:
13
+ """build_wave_context derives the stacked-wave predecessor branch."""
14
+
15
+ def test_no_predecessor_when_field_absent(self):
16
+ wave = build_wave_context(_make_wave_response())
17
+ assert wave is not None
18
+ assert wave.predecessor_branch is None
19
+
20
+ def test_uses_predecessor_wave_name_as_branch(self):
21
+ response = _make_wave_response()
22
+ response["wave"]["predecessor_wave"] = {"name": "wave-aaa111"}
23
+ wave = build_wave_context(response)
24
+ assert wave is not None
25
+ assert wave.predecessor_branch == "wave-aaa111"
26
+
27
+ def test_blank_predecessor_name_yields_no_base(self):
28
+ # A blank name is not a valid branch — never guess "wave-N".
29
+ response = _make_wave_response()
30
+ response["wave"]["predecessor_wave"] = {"name": ""}
31
+ wave = build_wave_context(response)
32
+ assert wave is not None
33
+ assert wave.predecessor_branch is None
34
+
35
+
11
36
  def _make_wave_context() -> WaveContext:
12
37
  return WaveContext(
13
38
  wave_number=3,
@@ -6,7 +6,23 @@ from unittest.mock import patch
6
6
  from steerdev_agent.config.models import EvidenceConfig, WorktreeConfig
7
7
  from steerdev_agent.prompt.builder import WaveContext
8
8
  from steerdev_agent.runner import Runner
9
- from steerdev_agent.worktree import compute_branch_name
9
+ from steerdev_agent.worktree import compute_branch_name, wave_branch_name
10
+
11
+
12
+ class TestWaveBranchName:
13
+ """The single source of truth for a wave's branch name."""
14
+
15
+ def test_uses_name_when_present(self):
16
+ assert wave_branch_name("wave-a1b2c3", 3) == "wave-a1b2c3"
17
+
18
+ def test_falls_back_to_number(self):
19
+ assert wave_branch_name("", 3) == "wave-3"
20
+ assert wave_branch_name(None, 4) == "wave-4"
21
+
22
+ def test_matches_compute_branch_name_wave_output(self):
23
+ wave = WaveContext(wave_number=3, total_waves=5, wave_name="wave-a1b2c3")
24
+ task = {"id": "abc123", "title": "Something"}
25
+ assert wave_branch_name(wave.wave_name, wave.wave_number) == compute_branch_name(task, wave)
10
26
 
11
27
 
12
28
  class TestComputeBranchName:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes