steerdev 1.0.60__tar.gz → 1.1.3__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 (116) hide show
  1. {steerdev-1.0.60 → steerdev-1.1.3}/PKG-INFO +1 -1
  2. {steerdev-1.0.60 → steerdev-1.1.3}/pyproject.toml +1 -1
  3. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/prompt/builder.py +1 -0
  4. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/runner.py +4 -3
  5. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workspace/preparation.py +133 -11
  6. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/worktree.py +2 -2
  7. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_preparation.py +312 -6
  8. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_runner_merge_modes.py +2 -0
  9. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_runner_worktrees.py +4 -4
  10. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_worktree.py +2 -2
  11. {steerdev-1.0.60 → steerdev-1.1.3}/.github/workflows/pre-commit.yml +0 -0
  12. {steerdev-1.0.60 → steerdev-1.1.3}/.github/workflows/publish.yml +0 -0
  13. {steerdev-1.0.60 → steerdev-1.1.3}/.gitignore +0 -0
  14. {steerdev-1.0.60 → steerdev-1.1.3}/.pre-commit-config.yaml +0 -0
  15. {steerdev-1.0.60 → steerdev-1.1.3}/AGENTS.md +0 -0
  16. {steerdev-1.0.60 → steerdev-1.1.3}/CLAUDE.md +0 -0
  17. {steerdev-1.0.60 → steerdev-1.1.3}/README.md +0 -0
  18. {steerdev-1.0.60 → steerdev-1.1.3}/scripts/pre-commit-version-bump.sh +0 -0
  19. {steerdev-1.0.60 → steerdev-1.1.3}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  20. {steerdev-1.0.60 → steerdev-1.1.3}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  21. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/__init__.py +0 -0
  22. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/agent_loop.py +0 -0
  23. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/__init__.py +0 -0
  24. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/activity.py +0 -0
  25. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/agents.py +0 -0
  26. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/canals.py +0 -0
  27. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/client.py +0 -0
  28. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/commands.py +0 -0
  29. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/configs.py +0 -0
  30. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/context.py +0 -0
  31. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/events.py +0 -0
  32. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/hooks.py +0 -0
  33. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/implementation_plan.py +0 -0
  34. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/merger.py +0 -0
  35. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/messages.py +0 -0
  36. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/prd.py +0 -0
  37. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/reports.py +0 -0
  38. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/runs.py +0 -0
  39. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/sessions.py +0 -0
  40. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/specs.py +0 -0
  41. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/tasks.py +0 -0
  42. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/workflow_runs.py +0 -0
  43. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/api/workflows.py +0 -0
  44. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/cli.py +0 -0
  45. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/config/__init__.py +0 -0
  46. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/config/models.py +0 -0
  47. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/config/platform.py +0 -0
  48. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/config/settings.py +0 -0
  49. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/evidence.py +0 -0
  50. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/evidence_assets.py +0 -0
  51. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/executor/__init__.py +0 -0
  52. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/executor/base.py +0 -0
  53. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/executor/claude.py +0 -0
  54. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/executor/stream.py +0 -0
  55. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/handlers/__init__.py +0 -0
  56. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/handlers/prd.py +0 -0
  57. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/integration.py +0 -0
  58. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/prompt/__init__.py +0 -0
  59. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/prompt/templates.py +0 -0
  60. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  61. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/py.typed +0 -0
  62. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/retry.py +0 -0
  63. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/__init__.py +0 -0
  64. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/claude_setup.py +0 -0
  65. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/repo_setup.py +0 -0
  66. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  67. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  68. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/settings.json +0 -0
  69. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  70. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  71. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  72. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  73. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  74. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  75. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  76. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  77. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  78. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  79. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  80. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  81. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/update_check.py +0 -0
  82. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/version.py +0 -0
  83. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workflow/__init__.py +0 -0
  84. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workflow/context.py +0 -0
  85. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workflow/executor.py +0 -0
  86. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workflow/memory.py +0 -0
  87. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workspace/__init__.py +0 -0
  88. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workspace/project_manager.py +0 -0
  89. {steerdev-1.0.60 → steerdev-1.1.3}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  90. {steerdev-1.0.60 → steerdev-1.1.3}/tests/__init__.py +0 -0
  91. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_agent_loop.py +0 -0
  92. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_agent_loop_extended.py +0 -0
  93. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_agents_api.py +0 -0
  94. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_api_client.py +0 -0
  95. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_claude_executor.py +0 -0
  96. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_claude_setup.py +0 -0
  97. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_client_methods.py +0 -0
  98. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_commands_api.py +0 -0
  99. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_config.py +0 -0
  100. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_config_extended.py +0 -0
  101. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_conflict_mitigation.py +0 -0
  102. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_context_search.py +0 -0
  103. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_executor.py +0 -0
  104. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_platform_config.py +0 -0
  105. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_prompt.py +0 -0
  106. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_reports_client.py +0 -0
  107. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_retry.py +0 -0
  108. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_stream_parser.py +0 -0
  109. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_tasks.py +0 -0
  110. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_version.py +0 -0
  111. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workflow_context.py +0 -0
  112. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workflow_memory.py +0 -0
  113. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workflow_prompt_template.py +0 -0
  114. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workflow_runs_api.py +0 -0
  115. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workspace.py +0 -0
  116. {steerdev-1.0.60 → steerdev-1.1.3}/tests/test_workspace_extended.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.0.60
3
+ Version: 1.1.3
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.0.60"
3
+ version = "1.1.3"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -24,6 +24,7 @@ class WaveContext(BaseModel):
24
24
 
25
25
  wave_number: int = Field(description="Current wave number")
26
26
  total_waves: int = Field(description="Total number of waves")
27
+ wave_name: str = Field(default="", description="Unique wave name (e.g., wave-532d5c)")
27
28
  wave_description: str = Field(default="", description="Description of this wave")
28
29
  wave_tasks_summary: str = Field(default="", description="Summary of tasks in this wave")
29
30
  completed_waves_summary: str = Field(default="", description="Summary of completed waves")
@@ -111,6 +111,7 @@ def build_wave_context(wave_response: dict[str, Any]) -> WaveContext | None:
111
111
  return WaveContext(
112
112
  wave_number=wave_info.get("wave_number", 1),
113
113
  total_waves=wave_info.get("total_waves", 1),
114
+ wave_name=wave_info.get("name", ""),
114
115
  wave_description=wave_info.get("description", ""),
115
116
  wave_tasks_summary=tasks_summary,
116
117
  completed_waves_summary=completed_summary,
@@ -187,13 +188,13 @@ def compute_worktree_name(task_id: str, wave_context: WaveContext | None = None)
187
188
  """Compute the git worktree name for a task execution.
188
189
 
189
190
  Naming conventions:
190
- - Wave-based: "wave-{wave_number}" (e.g., "wave-3")
191
+ - Wave-based: "{wave_name}" (e.g., "wave-532d5c")
191
192
  - Task-based: "task-{first_8_chars}" (e.g., "task-abc12345")
192
193
 
193
194
  The worktree lifecycle is managed by Claude CLI via the --worktree flag.
194
195
  """
195
196
  if wave_context:
196
- return f"wave-{wave_context.wave_number}"
197
+ return wave_context.wave_name or f"wave-{wave_context.wave_number}"
197
198
  task_short = task_id[:8] if len(task_id) > 8 else task_id
198
199
  return f"task-{task_short}"
199
200
 
@@ -643,7 +644,7 @@ class Runner:
643
644
 
644
645
  wave_id: str | None = None
645
646
  if wave_context:
646
- wave_id = f"wave-{wave_context.wave_number}"
647
+ wave_id = wave_context.wave_name or f"wave-{wave_context.wave_number}"
647
648
 
648
649
  try:
649
650
  with ReportsClient(api_key=self._api_key) as client:
@@ -35,6 +35,8 @@ class PreparationResult:
35
35
  is_worktree: bool
36
36
  error: str | None = None
37
37
  warnings: list[str] = field(default_factory=list)
38
+ repo_directories: list[Path] = field(default_factory=list)
39
+ """For multi-repo workspaces, the list of prepared git repositories."""
38
40
 
39
41
 
40
42
  async def _run_git(
@@ -145,13 +147,23 @@ class WorkspacePreparation:
145
147
  default_branch: str,
146
148
  wave_context: WaveContext | None,
147
149
  ) -> PreparationResult:
148
- """Prepare workspace in the main repository directory."""
150
+ """Prepare workspace in the main repository directory.
151
+
152
+ If the working directory is not itself a git repository, discovers
153
+ git repos in immediate subdirectories (multi-repo layout) and
154
+ prepares each one.
155
+ """
149
156
  cwd = self.working_directory
150
- warnings: list[str] = []
151
157
 
152
- # 1. Validate git repo
158
+ # 1. Check if working directory is a git repo
153
159
  rc, _, _ = await _run_git("rev-parse", "--is-inside-work-tree", cwd=cwd, check=False)
154
- if rc != 0:
160
+ if rc == 0:
161
+ # Single-repo layout: prepare in place
162
+ return await self._prepare_single_repo(cwd, target_branch, default_branch, wave_context)
163
+
164
+ # Not a git repo — check for multi-repo layout (subdirectories that are git repos)
165
+ repos = await self._discover_git_repos(cwd)
166
+ if not repos:
155
167
  return PreparationResult(
156
168
  success=False,
157
169
  branch_name=target_branch,
@@ -161,10 +173,120 @@ class WorkspacePreparation:
161
173
  error=f"Not a git repository: {cwd}",
162
174
  )
163
175
 
164
- # 2. Check current branch
176
+ # Multi-repo layout: prepare each discovered repo
177
+ return await self._prepare_multi_repo(repos, target_branch, default_branch, wave_context)
178
+
179
+ async def _discover_git_repos(self, directory: Path) -> list[Path]:
180
+ """Discover git repositories in immediate subdirectories.
181
+
182
+ Args:
183
+ directory: Parent directory to scan.
184
+
185
+ Returns:
186
+ Sorted list of paths that are git repositories.
187
+ """
188
+ repos: list[Path] = []
189
+ try:
190
+ for entry in sorted(directory.iterdir()):
191
+ if entry.is_dir() and not entry.name.startswith("."):
192
+ rc, _, _ = await _run_git(
193
+ "rev-parse", "--is-inside-work-tree", cwd=entry, check=False
194
+ )
195
+ if rc == 0:
196
+ repos.append(entry)
197
+ except OSError as exc:
198
+ logger.warning(f"Failed to scan directory {directory}: {exc}")
199
+ return repos
200
+
201
+ async def _prepare_multi_repo(
202
+ self,
203
+ repos: list[Path],
204
+ target_branch: str,
205
+ default_branch: str,
206
+ wave_context: WaveContext | None,
207
+ ) -> PreparationResult:
208
+ """Prepare multiple git repositories in a workspace directory.
209
+
210
+ Applies the same branch preparation to each discovered repo.
211
+ Returns success only if all repos are prepared successfully.
212
+
213
+ Args:
214
+ repos: List of git repository paths to prepare.
215
+ target_branch: Target branch name for the task.
216
+ default_branch: Default branch to sync from.
217
+ wave_context: Optional wave context.
218
+
219
+ Returns:
220
+ PreparationResult with the parent directory as working_directory.
221
+ """
222
+ parent_dir = self.working_directory
223
+ all_warnings: list[str] = []
224
+ prepared_repos: list[Path] = []
225
+
226
+ logger.info(
227
+ f"Multi-repo workspace detected at {parent_dir}: preparing {len(repos)} repositories"
228
+ )
229
+
230
+ for repo_path in repos:
231
+ repo_name = repo_path.name
232
+ logger.info(f"Preparing repository: {repo_name}")
233
+
234
+ result = await self._prepare_single_repo(
235
+ repo_path, target_branch, default_branch, wave_context
236
+ )
237
+
238
+ if not result.success:
239
+ return PreparationResult(
240
+ success=False,
241
+ branch_name=target_branch,
242
+ working_directory=parent_dir,
243
+ default_branch=default_branch,
244
+ is_worktree=False,
245
+ error=f"Failed to prepare repo {repo_name}: {result.error}",
246
+ repo_directories=prepared_repos,
247
+ )
248
+
249
+ prepared_repos.append(repo_path)
250
+ # Prefix per-repo warnings with the repo name for clarity
251
+ for warning in result.warnings:
252
+ all_warnings.append(f"[{repo_name}] {warning}")
253
+
254
+ logger.info(f"Multi-repo workspace ready: {len(prepared_repos)} repositories prepared")
255
+
256
+ return PreparationResult(
257
+ success=True,
258
+ branch_name=target_branch,
259
+ working_directory=parent_dir,
260
+ default_branch=default_branch,
261
+ is_worktree=False,
262
+ warnings=all_warnings,
263
+ repo_directories=prepared_repos,
264
+ )
265
+
266
+ async def _prepare_single_repo(
267
+ self,
268
+ cwd: Path,
269
+ target_branch: str,
270
+ default_branch: str,
271
+ wave_context: WaveContext | None,
272
+ ) -> PreparationResult:
273
+ """Prepare a single git repository directory.
274
+
275
+ Args:
276
+ cwd: Path to the git repository.
277
+ target_branch: Target branch name for the task.
278
+ default_branch: Default branch to sync from.
279
+ wave_context: Optional wave context.
280
+
281
+ Returns:
282
+ PreparationResult for this repository.
283
+ """
284
+ warnings: list[str] = []
285
+
286
+ # 1. Check current branch
165
287
  _, current_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
166
288
 
167
- # 3. If continuing a wave on the same branch, just fetch
289
+ # 2. If continuing a wave on the same branch, just fetch
168
290
  if wave_context and current_branch == target_branch:
169
291
  logger.info(f"Continuing wave on branch {target_branch}, fetching only")
170
292
  await _run_git("fetch", "origin", cwd=cwd, check=False)
@@ -176,17 +298,17 @@ class WorkspacePreparation:
176
298
  is_worktree=False,
177
299
  )
178
300
 
179
- # 4. Stash if dirty
301
+ # 3. Stash if dirty
180
302
  rc, status_output, _ = await _run_git("status", "--porcelain", cwd=cwd, check=False)
181
303
  if status_output:
182
304
  logger.info("Working directory dirty, stashing changes")
183
305
  await _run_git("stash", "push", "-m", "steerdev-preflight", cwd=cwd, check=False)
184
306
  warnings.append("Stashed uncommitted changes (steerdev-preflight)")
185
307
 
186
- # 5. Fetch from origin
308
+ # 4. Fetch from origin
187
309
  await _run_git("fetch", "origin", cwd=cwd, check=False)
188
310
 
189
- # 6. Sync to default branch
311
+ # 5. Sync to default branch
190
312
  rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
191
313
  if rc != 0:
192
314
  return PreparationResult(
@@ -210,7 +332,7 @@ class WorkspacePreparation:
210
332
  f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
211
333
  )
212
334
 
213
- # 7. Create or checkout target branch
335
+ # 6. Create or checkout target branch
214
336
  rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
215
337
  branch_exists = rc == 0
216
338
 
@@ -221,7 +343,7 @@ class WorkspacePreparation:
221
343
  await _run_git("checkout", "-b", target_branch, cwd=cwd)
222
344
  logger.info(f"Created new branch: {target_branch}")
223
345
 
224
- # 8. Validate current branch
346
+ # 7. Validate current branch
225
347
  _, actual_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
226
348
  if actual_branch != target_branch:
227
349
  return PreparationResult(
@@ -213,7 +213,7 @@ def compute_branch_name(
213
213
  """Compute the branch name for a task or wave.
214
214
 
215
215
  Naming conventions aligned with merge skill branch patterns:
216
- - Wave: "wave/{wave_number}" (e.g., "wave/3")
216
+ - Wave: "{wave_name}" (e.g., "wave-532d5c")
217
217
  - Task with external ID: "task/{TICKET-ID}-{slug}" (e.g., "task/PROJ-123-add-auth")
218
218
  - Task without external ID: "task/{short-id}-{slug}" (e.g., "task/abc12345-add-auth")
219
219
 
@@ -225,7 +225,7 @@ def compute_branch_name(
225
225
  Branch name string.
226
226
  """
227
227
  if wave_context:
228
- return f"wave/{wave_context.wave_number}"
228
+ return wave_context.wave_name or f"wave-{wave_context.wave_number}"
229
229
 
230
230
  # Try external ticket ID first (e.g., Linear "PROJ-123")
231
231
  external_id = task.get("linear_identifier") or task.get("external_id")
@@ -211,7 +211,7 @@ class TestWorkspacePreparationInPlace:
211
211
  """Continuing a wave stays on the wave branch, only fetches."""
212
212
  from steerdev_agent.prompt.builder import WaveContext
213
213
 
214
- wave_ctx = WaveContext(wave_number=3, total_waves=5)
214
+ wave_ctx = WaveContext(wave_number=3, total_waves=5, wave_name="wave-a1b2c3")
215
215
 
216
216
  prep = WorkspacePreparation(
217
217
  working_directory=Path("/tmp/repo"),
@@ -227,7 +227,7 @@ class TestWorkspacePreparationInPlace:
227
227
  if "rev-parse --is-inside-work-tree" in cmd:
228
228
  return (0, "true", "")
229
229
  if "branch --show-current" in cmd:
230
- return (0, "wave/3", "") # Already on wave branch
230
+ return (0, "wave-a1b2c3", "") # Already on wave branch
231
231
  if "fetch origin" in cmd:
232
232
  return (0, "", "")
233
233
  return (0, "", "")
@@ -236,7 +236,7 @@ class TestWorkspacePreparationInPlace:
236
236
  result = await prep.prepare(sample_task, wave_ctx)
237
237
 
238
238
  assert result.success is True
239
- assert result.branch_name == "wave/3"
239
+ assert result.branch_name == "wave-a1b2c3"
240
240
  # Should NOT have checkout commands (stayed on branch)
241
241
  checkout_cmds = [c for c in calls if c[0] == "checkout"]
242
242
  assert len(checkout_cmds) == 0
@@ -290,10 +290,12 @@ class TestWorkspacePreparationInPlace:
290
290
  assert len(stash_cmds) > 0
291
291
 
292
292
  @pytest.mark.asyncio
293
- async def test_not_git_repo_fails(self, worktree_disabled, default_config, sample_task):
294
- """Non-git directory returns failure."""
293
+ async def test_not_git_repo_no_subrepos_fails(
294
+ self, worktree_disabled, default_config, sample_task, tmp_path
295
+ ):
296
+ """Non-git directory with no sub-repos returns failure."""
295
297
  prep = WorkspacePreparation(
296
- working_directory=Path("/tmp/not-a-repo"),
298
+ working_directory=tmp_path,
297
299
  worktree_config=worktree_disabled,
298
300
  branch_config=default_config,
299
301
  )
@@ -356,3 +358,307 @@ class TestWorkspacePreparationInPlace:
356
358
  reset_cmds = [c for c in calls if "reset" in " ".join(c)]
357
359
  assert len(reset_cmds) > 0
358
360
  assert any("fast-forward" in w.lower() or "reset" in w.lower() for w in result.warnings)
361
+
362
+
363
+ class TestWorkspacePreparationMultiRepo:
364
+ """Tests for multi-repo workspace preparation."""
365
+
366
+ @pytest.fixture
367
+ def worktree_disabled(self):
368
+ return WorktreeConfig(enabled=False)
369
+
370
+ @pytest.fixture
371
+ def default_config(self):
372
+ return BranchConfig(default_branch="main")
373
+
374
+ @pytest.fixture
375
+ def sample_task(self):
376
+ return {
377
+ "id": "abc12345-6789-0000-0000-000000000000",
378
+ "title": "Add user authentication",
379
+ "prompt": "Implement user auth",
380
+ "status": "started",
381
+ }
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_multi_repo_discovers_and_prepares_all(
385
+ self, worktree_disabled, default_config, sample_task, tmp_path
386
+ ):
387
+ """Multi-repo layout: discovers sub-repos and prepares each one."""
388
+ # Create subdirectories to simulate multi-repo layout
389
+ (tmp_path / "repo_a").mkdir()
390
+ (tmp_path / "repo_b").mkdir()
391
+ (tmp_path / ".hidden").mkdir() # should be skipped
392
+
393
+ prep = WorkspacePreparation(
394
+ working_directory=tmp_path,
395
+ worktree_config=worktree_disabled,
396
+ branch_config=default_config,
397
+ )
398
+
399
+ calls: list[tuple[tuple[str, ...], Path]] = []
400
+
401
+ async def mock_run_git(*args, cwd, check=True):
402
+ calls.append((args, Path(cwd)))
403
+ cmd = " ".join(args)
404
+ cwd_path = Path(cwd)
405
+
406
+ if "rev-parse --is-inside-work-tree" in cmd:
407
+ # Parent dir is NOT a repo; subdirs ARE repos
408
+ if cwd_path == tmp_path:
409
+ return (128, "", "fatal: not a git repository")
410
+ if cwd_path.name in ("repo_a", "repo_b"):
411
+ return (0, "true", "")
412
+ return (128, "", "fatal: not a git repository")
413
+ if "branch --show-current" in cmd:
414
+ # Track per-repo calls to return correct branch after checkout
415
+ show_current_calls = [
416
+ c
417
+ for c in calls
418
+ if "branch --show-current" in " ".join(c[0]) and c[1] == cwd_path
419
+ ]
420
+ if len(show_current_calls) <= 1:
421
+ return (0, "main", "")
422
+ return (0, "task/abc12345-add-user-authentication", "")
423
+ if "status --porcelain" in cmd:
424
+ return (0, "", "")
425
+ if "fetch origin" in cmd:
426
+ return (0, "", "")
427
+ if "checkout main" in cmd:
428
+ return (0, "", "")
429
+ if "merge --ff-only" in cmd:
430
+ return (0, "", "")
431
+ if "rev-parse --verify" in cmd:
432
+ return (1, "", "") # branch doesn't exist
433
+ if "checkout -b" in cmd:
434
+ return (0, "", "")
435
+ if "checkout" in cmd:
436
+ return (0, "", "")
437
+ return (0, "", "")
438
+
439
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
440
+ result = await prep.prepare(sample_task)
441
+
442
+ assert result.success is True
443
+ assert result.working_directory == tmp_path # parent dir preserved
444
+ assert len(result.repo_directories) == 2
445
+ assert tmp_path / "repo_a" in result.repo_directories
446
+ assert tmp_path / "repo_b" in result.repo_directories
447
+ # Verify git operations ran against both repos
448
+ repo_a_checkouts = [
449
+ c for c in calls if c[1] == tmp_path / "repo_a" and "checkout -b" in " ".join(c[0])
450
+ ]
451
+ repo_b_checkouts = [
452
+ c for c in calls if c[1] == tmp_path / "repo_b" and "checkout -b" in " ".join(c[0])
453
+ ]
454
+ assert len(repo_a_checkouts) == 1
455
+ assert len(repo_b_checkouts) == 1
456
+
457
+ @pytest.mark.asyncio
458
+ async def test_multi_repo_fails_if_one_repo_fails(
459
+ self, worktree_disabled, default_config, sample_task, tmp_path
460
+ ):
461
+ """Multi-repo: failure in one repo fails the whole preparation."""
462
+ (tmp_path / "repo_a").mkdir()
463
+ (tmp_path / "repo_b").mkdir()
464
+
465
+ prep = WorkspacePreparation(
466
+ working_directory=tmp_path,
467
+ worktree_config=worktree_disabled,
468
+ branch_config=default_config,
469
+ )
470
+
471
+ # Track per-repo branch --show-current calls
472
+ branch_calls_per_repo: dict[str, int] = {}
473
+
474
+ async def mock_run_git(*args, cwd, check=True):
475
+ cmd = " ".join(args)
476
+ cwd_path = Path(cwd)
477
+ repo_name = cwd_path.name
478
+
479
+ if "rev-parse --is-inside-work-tree" in cmd:
480
+ if cwd_path == tmp_path:
481
+ return (128, "", "fatal: not a git repository")
482
+ if repo_name in ("repo_a", "repo_b"):
483
+ return (0, "true", "")
484
+ return (128, "", "")
485
+ if "branch --show-current" in cmd:
486
+ branch_calls_per_repo[repo_name] = branch_calls_per_repo.get(repo_name, 0) + 1
487
+ if branch_calls_per_repo[repo_name] <= 1:
488
+ return (0, "main", "")
489
+ return (0, "task/abc12345-add-user-authentication", "")
490
+ if "status --porcelain" in cmd:
491
+ return (0, "", "")
492
+ if "fetch origin" in cmd:
493
+ return (0, "", "")
494
+ if "checkout main" in cmd:
495
+ # repo_b fails to checkout default branch
496
+ if repo_name == "repo_b":
497
+ return (1, "", "error: pathspec 'main' did not match")
498
+ return (0, "", "")
499
+ if "merge --ff-only" in cmd:
500
+ return (0, "", "")
501
+ if "rev-parse --verify" in cmd:
502
+ return (1, "", "")
503
+ if "checkout -b" in cmd:
504
+ return (0, "", "")
505
+ return (0, "", "")
506
+
507
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
508
+ result = await prep.prepare(sample_task)
509
+
510
+ assert result.success is False
511
+ assert "repo_b" in (result.error or "")
512
+ # repo_a was prepared before repo_b failed
513
+ assert len(result.repo_directories) == 1
514
+ assert tmp_path / "repo_a" in result.repo_directories
515
+
516
+ @pytest.mark.asyncio
517
+ async def test_multi_repo_collects_warnings_with_prefix(
518
+ self, worktree_disabled, default_config, sample_task, tmp_path
519
+ ):
520
+ """Multi-repo: warnings are prefixed with repo name."""
521
+ (tmp_path / "repo_a").mkdir()
522
+
523
+ prep = WorkspacePreparation(
524
+ working_directory=tmp_path,
525
+ worktree_config=worktree_disabled,
526
+ branch_config=default_config,
527
+ )
528
+
529
+ branch_call_count = [0]
530
+
531
+ async def mock_run_git(*args, cwd, check=True):
532
+ cmd = " ".join(args)
533
+ cwd_path = Path(cwd)
534
+
535
+ if "rev-parse --is-inside-work-tree" in cmd:
536
+ if cwd_path == tmp_path:
537
+ return (128, "", "fatal: not a git repository")
538
+ return (0, "true", "")
539
+ if "branch --show-current" in cmd:
540
+ # Return target branch on validation check
541
+ branch_call_count[0] += 1
542
+ if branch_call_count[0] > 1:
543
+ return (0, "task/abc12345-add-user-authentication", "")
544
+ return (0, "main", "")
545
+ if "status --porcelain" in cmd:
546
+ return (0, "M dirty.py", "") # dirty repo
547
+ if "stash push" in cmd:
548
+ return (0, "", "")
549
+ if "fetch origin" in cmd:
550
+ return (0, "", "")
551
+ if "checkout main" in cmd:
552
+ return (0, "", "")
553
+ if "merge --ff-only" in cmd:
554
+ return (0, "", "")
555
+ if "rev-parse --verify" in cmd:
556
+ return (1, "", "")
557
+ if "checkout -b" in cmd:
558
+ return (0, "", "")
559
+ return (0, "", "")
560
+
561
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
562
+ result = await prep.prepare(sample_task)
563
+
564
+ assert result.success is True
565
+ assert any("[repo_a]" in w for w in result.warnings)
566
+
567
+ @pytest.mark.asyncio
568
+ async def test_multi_repo_skips_hidden_and_non_repo_dirs(
569
+ self, worktree_disabled, default_config, sample_task, tmp_path
570
+ ):
571
+ """Multi-repo: hidden dirs and non-repo dirs are skipped."""
572
+ (tmp_path / ".hidden_repo").mkdir()
573
+ (tmp_path / "not_a_repo").mkdir()
574
+ (tmp_path / "actual_repo").mkdir()
575
+
576
+ prep = WorkspacePreparation(
577
+ working_directory=tmp_path,
578
+ worktree_config=worktree_disabled,
579
+ branch_config=default_config,
580
+ )
581
+
582
+ calls_by_cwd: dict[str, list[str]] = {}
583
+
584
+ async def mock_run_git(*args, cwd, check=True):
585
+ cmd = " ".join(args)
586
+ cwd_path = Path(cwd)
587
+ calls_by_cwd.setdefault(cwd_path.name, []).append(cmd)
588
+
589
+ if "rev-parse --is-inside-work-tree" in cmd:
590
+ if cwd_path == tmp_path:
591
+ return (128, "", "fatal: not a git repository")
592
+ if cwd_path.name == "actual_repo":
593
+ return (0, "true", "")
594
+ return (128, "", "fatal: not a git repository")
595
+ if "branch --show-current" in cmd:
596
+ branch_calls = [
597
+ c for c in calls_by_cwd.get(cwd_path.name, []) if "branch --show-current" in c
598
+ ]
599
+ if len(branch_calls) <= 1:
600
+ return (0, "main", "")
601
+ return (0, "task/abc12345-add-user-authentication", "")
602
+ if "status --porcelain" in cmd:
603
+ return (0, "", "")
604
+ if "fetch origin" in cmd:
605
+ return (0, "", "")
606
+ if "checkout main" in cmd:
607
+ return (0, "", "")
608
+ if "merge --ff-only" in cmd:
609
+ return (0, "", "")
610
+ if "rev-parse --verify" in cmd:
611
+ return (1, "", "")
612
+ if "checkout -b" in cmd:
613
+ return (0, "", "")
614
+ return (0, "", "")
615
+
616
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
617
+ result = await prep.prepare(sample_task)
618
+
619
+ assert result.success is True
620
+ assert len(result.repo_directories) == 1
621
+ assert tmp_path / "actual_repo" in result.repo_directories
622
+ # Hidden dir should never have been checked
623
+ assert ".hidden_repo" not in calls_by_cwd
624
+
625
+ @pytest.mark.asyncio
626
+ async def test_single_repo_still_works(self, worktree_disabled, default_config, sample_task):
627
+ """Single-repo layout still works as before (no regressions)."""
628
+ prep = WorkspacePreparation(
629
+ working_directory=Path("/tmp/repo"),
630
+ worktree_config=worktree_disabled,
631
+ branch_config=default_config,
632
+ )
633
+
634
+ calls = []
635
+
636
+ async def mock_run_git(*args, cwd, check=True):
637
+ calls.append(args)
638
+ cmd = " ".join(args)
639
+ if "rev-parse --is-inside-work-tree" in cmd:
640
+ return (0, "true", "")
641
+ if "branch --show-current" in cmd:
642
+ if len([c for c in calls if "branch --show-current" in " ".join(c)]) <= 1:
643
+ return (0, "main", "")
644
+ return (0, "task/abc12345-add-user-authentication", "")
645
+ if "status --porcelain" in cmd:
646
+ return (0, "", "")
647
+ if "fetch origin" in cmd:
648
+ return (0, "", "")
649
+ if "checkout main" in cmd:
650
+ return (0, "", "")
651
+ if "merge --ff-only" in cmd:
652
+ return (0, "", "")
653
+ if "rev-parse --verify" in cmd:
654
+ return (1, "", "")
655
+ if "checkout -b" in cmd:
656
+ return (0, "", "")
657
+ return (0, "", "")
658
+
659
+ with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
660
+ result = await prep.prepare(sample_task)
661
+
662
+ assert result.success is True
663
+ assert result.repo_directories == [] # single-repo: no repo_directories
664
+ assert result.working_directory == Path("/tmp/repo")
@@ -12,6 +12,7 @@ def _make_wave_context() -> WaveContext:
12
12
  return WaveContext(
13
13
  wave_number=3,
14
14
  total_waves=5,
15
+ wave_name="wave-a1b2c3",
15
16
  wave_description="User management improvements",
16
17
  wave_tasks_summary=" [todo] Add user endpoint",
17
18
  completed_waves_summary=" - Wave 2: Auth cleanup",
@@ -23,6 +24,7 @@ def _make_wave_response(task_id: str = "task-123") -> dict:
23
24
  "wave": {
24
25
  "wave_number": 3,
25
26
  "total_waves": 5,
27
+ "name": "wave-a1b2c3",
26
28
  "description": "User management improvements",
27
29
  },
28
30
  "context": {
@@ -25,13 +25,13 @@ class TestComputeBranchName:
25
25
 
26
26
  def test_wave_based(self):
27
27
  task = {"id": "abc123", "title": "Something"}
28
- wave = WaveContext(wave_number=3, total_waves=5)
29
- assert compute_branch_name(task, wave) == "wave/3"
28
+ wave = WaveContext(wave_number=3, total_waves=5, wave_name="wave-a1b2c3")
29
+ assert compute_branch_name(task, wave) == "wave-a1b2c3"
30
30
 
31
31
  def test_wave_takes_precedence(self):
32
32
  task = {"id": "abc12345xyz", "title": "Add auth", "linear_identifier": "PROJ-123"}
33
- wave = WaveContext(wave_number=2, total_waves=4)
34
- assert compute_branch_name(task, wave) == "wave/2"
33
+ wave = WaveContext(wave_number=2, total_waves=4, wave_name="wave-f1e2d3")
34
+ assert compute_branch_name(task, wave) == "wave-f1e2d3"
35
35
 
36
36
  def test_title_slugified(self):
37
37
  task = {"id": "abc12345xyz", "title": "Add User Authentication & OAuth 2.0"}
@@ -37,8 +37,8 @@ class TestComputeBranchName:
37
37
  from steerdev_agent.prompt.builder import WaveContext
38
38
 
39
39
  task = {"id": "abc", "title": "test"}
40
- wave = WaveContext(wave_number=5, total_waves=10)
41
- assert compute_branch_name(task, wave) == "wave/5"
40
+ wave = WaveContext(wave_number=5, total_waves=10, wave_name="wave-d4e5f6")
41
+ assert compute_branch_name(task, wave) == "wave-d4e5f6"
42
42
 
43
43
  def test_task_with_linear_id(self):
44
44
  task = {"id": "abc12345xyz", "title": "Add auth", "linear_identifier": "PROJ-42"}
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