steerdev 1.1.31__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.31 → steerdev-1.1.43}/PKG-INFO +1 -1
  2. {steerdev-1.1.31 → steerdev-1.1.43}/pyproject.toml +1 -1
  3. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/cli.py +14 -0
  4. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/runner.py +8 -0
  5. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +17 -0
  6. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workspace/preparation.py +196 -61
  7. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_preparation.py +418 -0
  8. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_runner_worktrees.py +8 -0
  9. {steerdev-1.1.31 → steerdev-1.1.43}/.github/workflows/pre-commit.yml +0 -0
  10. {steerdev-1.1.31 → steerdev-1.1.43}/.github/workflows/publish.yml +0 -0
  11. {steerdev-1.1.31 → steerdev-1.1.43}/.gitignore +0 -0
  12. {steerdev-1.1.31 → steerdev-1.1.43}/.pre-commit-config.yaml +0 -0
  13. {steerdev-1.1.31 → steerdev-1.1.43}/AGENTS.md +0 -0
  14. {steerdev-1.1.31 → steerdev-1.1.43}/CLAUDE.md +0 -0
  15. {steerdev-1.1.31 → steerdev-1.1.43}/README.md +0 -0
  16. {steerdev-1.1.31 → steerdev-1.1.43}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  17. {steerdev-1.1.31 → steerdev-1.1.43}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  18. {steerdev-1.1.31 → steerdev-1.1.43}/scripts/pre-commit-version-bump.sh +0 -0
  19. {steerdev-1.1.31 → steerdev-1.1.43}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  20. {steerdev-1.1.31 → steerdev-1.1.43}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  21. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/__init__.py +0 -0
  22. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/agent_loop.py +0 -0
  23. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/__init__.py +0 -0
  24. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/activity.py +0 -0
  25. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/agents.py +0 -0
  26. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/canals.py +0 -0
  27. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/client.py +0 -0
  28. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/commands.py +0 -0
  29. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/configs.py +0 -0
  30. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/context.py +0 -0
  31. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/events.py +0 -0
  32. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/hooks.py +0 -0
  33. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/implementation_plan.py +0 -0
  34. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/merger.py +0 -0
  35. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/messages.py +0 -0
  36. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/prd.py +0 -0
  37. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/reports.py +0 -0
  38. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/runs.py +0 -0
  39. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/sessions.py +0 -0
  40. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/specs.py +0 -0
  41. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/tasks.py +0 -0
  42. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/workflow_runs.py +0 -0
  43. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/api/workflows.py +0 -0
  44. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/config/__init__.py +0 -0
  45. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/config/models.py +0 -0
  46. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/config/platform.py +0 -0
  47. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/config/settings.py +0 -0
  48. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/evidence.py +0 -0
  49. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/evidence_assets.py +0 -0
  50. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/executor/__init__.py +0 -0
  51. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/executor/base.py +0 -0
  52. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/executor/claude.py +0 -0
  53. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/executor/stream.py +0 -0
  54. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/git/__init__.py +0 -0
  55. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/git/commit.py +0 -0
  56. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/git_ops.py +0 -0
  57. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/handlers/__init__.py +0 -0
  58. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/handlers/prd.py +0 -0
  59. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/integration.py +0 -0
  60. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/prompt/__init__.py +0 -0
  61. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/prompt/builder.py +0 -0
  62. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/prompt/templates.py +0 -0
  63. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  64. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/py.typed +0 -0
  65. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/retry.py +0 -0
  66. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/__init__.py +0 -0
  67. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/claude_setup.py +0 -0
  68. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/repo_setup.py +0 -0
  69. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  70. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  71. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/settings.json +0 -0
  72. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  73. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  74. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  75. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  76. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  77. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  78. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  79. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  80. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  81. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  82. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  83. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/update_check.py +0 -0
  84. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/version.py +0 -0
  85. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workflow/__init__.py +0 -0
  86. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workflow/context.py +0 -0
  87. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workflow/executor.py +0 -0
  88. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workflow/memory.py +0 -0
  89. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workspace/__init__.py +0 -0
  90. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workspace/project_manager.py +0 -0
  91. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  92. {steerdev-1.1.31 → steerdev-1.1.43}/src/steerdev_agent/worktree.py +0 -0
  93. {steerdev-1.1.31 → steerdev-1.1.43}/tests/__init__.py +0 -0
  94. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_agent_loop.py +0 -0
  95. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_agent_loop_extended.py +0 -0
  96. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_agents_api.py +0 -0
  97. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_api_client.py +0 -0
  98. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_claude_executor.py +0 -0
  99. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_claude_setup.py +0 -0
  100. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_client_methods.py +0 -0
  101. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_commands_api.py +0 -0
  102. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_config.py +0 -0
  103. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_config_extended.py +0 -0
  104. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_conflict_mitigation.py +0 -0
  105. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_context_search.py +0 -0
  106. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_executor.py +0 -0
  107. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_git_commit.py +0 -0
  108. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_git_ops.py +0 -0
  109. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_hooks_post_tool_use.py +0 -0
  110. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_platform_config.py +0 -0
  111. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_preparation_provenance.py +0 -0
  112. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_prompt.py +0 -0
  113. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_reports_client.py +0 -0
  114. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_resolve_max_tasks.py +0 -0
  115. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_retry.py +0 -0
  116. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_runner_boundary_commit.py +0 -0
  117. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_runner_merge_modes.py +0 -0
  118. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_stream_parser.py +0 -0
  119. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_tasks.py +0 -0
  120. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_tasks_update_branch.py +0 -0
  121. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_version.py +0 -0
  122. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_wave_scoped_fetch.py +0 -0
  123. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workflow_context.py +0 -0
  124. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workflow_memory.py +0 -0
  125. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workflow_prompt_template.py +0 -0
  126. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workflow_runs_api.py +0 -0
  127. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workspace.py +0 -0
  128. {steerdev-1.1.31 → steerdev-1.1.43}/tests/test_workspace_extended.py +0 -0
  129. {steerdev-1.1.31 → 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.31
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.31"
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
 
@@ -338,6 +338,7 @@ class Runner:
338
338
  agent_id: str | None = None,
339
339
  task_id: str | None = None,
340
340
  wave_id: str | None = None,
341
+ base_branch_override: str | None = None,
341
342
  ) -> None:
342
343
  """Initialize the runner.
343
344
 
@@ -363,6 +364,8 @@ class Runner:
363
364
  task_id: Run a single specific task (bypasses queue/wave scheduling).
364
365
  wave_id: Scope scheduling to a single wave; the loop processes every
365
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.
366
369
  """
367
370
  self.project_id = project_id
368
371
  self.working_directory = Path(working_directory or Path.cwd())
@@ -390,6 +393,7 @@ class Runner:
390
393
  self._agent_id = agent_id
391
394
  self._target_task_id = task_id
392
395
  self._target_wave_id = wave_id
396
+ self._base_branch_override = base_branch_override
393
397
 
394
398
  # State
395
399
  self._state = RunState.STOPPED
@@ -836,6 +840,7 @@ class Runner:
836
840
  working_directory=self.working_directory,
837
841
  worktree_config=self._worktree_config,
838
842
  branch_config=self._branch_config,
843
+ base_branch_override=self._base_branch_override,
839
844
  )
840
845
  prep_result = await preparation.prepare(task, wave_context)
841
846
  if not prep_result.success:
@@ -1053,6 +1058,7 @@ class Runner:
1053
1058
  working_directory=self.working_directory,
1054
1059
  worktree_config=self._worktree_config,
1055
1060
  branch_config=self._branch_config,
1061
+ base_branch_override=self._base_branch_override,
1056
1062
  )
1057
1063
  prep_result = await preparation.prepare(task, wave_context)
1058
1064
  if not prep_result.success:
@@ -1510,6 +1516,7 @@ async def run_agent(
1510
1516
  task_id: str | None = None,
1511
1517
  agent_id: str | None = None,
1512
1518
  wave_id: str | None = None,
1519
+ base_branch_override: str | None = None,
1513
1520
  ) -> dict[str, Any]:
1514
1521
  """Run the steerdev agent.
1515
1522
 
@@ -1560,6 +1567,7 @@ async def run_agent(
1560
1567
  task_id=task_id,
1561
1568
  agent_id=agent_id,
1562
1569
  wave_id=wave_id,
1570
+ base_branch_override=base_branch_override,
1563
1571
  )
1564
1572
 
1565
1573
  # Install signal handler that kills the subprocess immediately on Ctrl+C
@@ -63,6 +63,23 @@ 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
@@ -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,39 +391,36 @@ class WorkspacePreparation:
347
391
  # 4. Fetch from origin
348
392
  await _run_git("fetch", "origin", cwd=cwd, check=False)
349
393
 
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
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
- )
375
-
376
- # 6. Create or checkout the target (wave) branch.
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.
377
410
  rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
378
- branch_exists = rc == 0
411
+ branch_exists_local = rc == 0
412
+
413
+ rc, _, _ = await _run_git(
414
+ "rev-parse", "--verify", f"origin/{target_branch}", cwd=cwd, check=False
415
+ )
416
+ branch_exists_origin = rc == 0
379
417
 
380
- if branch_exists:
381
- # Branch already present locally (e.g. a continuing wave on a fresh
382
- # clone): just switch to it.
418
+ is_continuation = False
419
+ effective_base = default_branch
420
+
421
+ if branch_exists_local:
422
+ # Branch already present locally (same VM, continuing the wave).
423
+ is_continuation = True
383
424
  rc, _, _ = await _run_git("checkout", target_branch, cwd=cwd, check=False)
384
425
  if rc != 0:
385
426
  return PreparationResult(
@@ -391,11 +432,13 @@ class WorkspacePreparation:
391
432
  error=f"Failed to checkout existing branch {target_branch}",
392
433
  )
393
434
  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.
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
397
440
  rc, _, _ = await _run_git(
398
- "checkout", "-b", target_branch, f"origin/{effective_base}", cwd=cwd, check=False
441
+ "checkout", "-b", target_branch, f"origin/{target_branch}", cwd=cwd, check=False
399
442
  )
400
443
  if rc != 0:
401
444
  return PreparationResult(
@@ -404,36 +447,124 @@ class WorkspacePreparation:
404
447
  working_directory=cwd,
405
448
  default_branch=default_branch,
406
449
  is_worktree=False,
407
- error=f"Failed to cut {target_branch} from origin/{effective_base}",
450
+ error=f"Failed to track existing branch origin/{target_branch}",
408
451
  )
409
- logger.info(f"Created {target_branch} stacked on origin/{effective_base}")
452
+ logger.info(f"Continuing wave branch from origin/{target_branch}")
410
453
  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?",
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
421
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
+ )
422
495
 
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"
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,
429
506
  )
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}"
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
433
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
+ )
434
554
 
435
- await _run_git("checkout", "-b", target_branch, cwd=cwd)
436
- logger.info(f"Created new branch: {target_branch}")
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
437
568
 
438
569
  # 7. Validate current branch
439
570
  _, actual_branch, _ = await _run_git("branch", "--show-current", cwd=cwd, check=False)
@@ -447,9 +578,13 @@ class WorkspacePreparation:
447
578
  error=f"Branch mismatch: expected {target_branch}, got {actual_branch}",
448
579
  )
449
580
 
450
- await persist_branch_provenance(
451
- task_id=task_id, branch=target_branch, base_branch=effective_base
452
- )
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
+ )
453
588
 
454
589
  return PreparationResult(
455
590
  success=True,