steerdev 1.1.43__tar.gz → 1.1.45__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 (133) hide show
  1. {steerdev-1.1.43 → steerdev-1.1.45}/PKG-INFO +1 -1
  2. {steerdev-1.1.43 → steerdev-1.1.45}/pyproject.toml +1 -1
  3. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/cli.py +42 -40
  4. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/executor/__init__.py +9 -1
  5. steerdev-1.1.45/src/steerdev_agent/git/pr.py +192 -0
  6. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/runner.py +88 -1
  7. steerdev-1.1.45/tests/test_git_pr.py +210 -0
  8. steerdev-1.1.45/tests/test_resolve_executor_config.py +85 -0
  9. steerdev-1.1.45/tests/test_runner_wave_pr.py +83 -0
  10. {steerdev-1.1.43 → steerdev-1.1.45}/.github/workflows/pre-commit.yml +0 -0
  11. {steerdev-1.1.43 → steerdev-1.1.45}/.github/workflows/publish.yml +0 -0
  12. {steerdev-1.1.43 → steerdev-1.1.45}/.gitignore +0 -0
  13. {steerdev-1.1.43 → steerdev-1.1.45}/.pre-commit-config.yaml +0 -0
  14. {steerdev-1.1.43 → steerdev-1.1.45}/AGENTS.md +0 -0
  15. {steerdev-1.1.43 → steerdev-1.1.45}/CLAUDE.md +0 -0
  16. {steerdev-1.1.43 → steerdev-1.1.45}/README.md +0 -0
  17. {steerdev-1.1.43 → steerdev-1.1.45}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  18. {steerdev-1.1.43 → steerdev-1.1.45}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  19. {steerdev-1.1.43 → steerdev-1.1.45}/scripts/pre-commit-version-bump.sh +0 -0
  20. {steerdev-1.1.43 → steerdev-1.1.45}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  21. {steerdev-1.1.43 → steerdev-1.1.45}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  22. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/__init__.py +0 -0
  23. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/agent_loop.py +0 -0
  24. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/__init__.py +0 -0
  25. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/activity.py +0 -0
  26. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/agents.py +0 -0
  27. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/canals.py +0 -0
  28. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/client.py +0 -0
  29. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/commands.py +0 -0
  30. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/configs.py +0 -0
  31. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/context.py +0 -0
  32. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/events.py +0 -0
  33. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/hooks.py +0 -0
  34. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/implementation_plan.py +0 -0
  35. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/merger.py +0 -0
  36. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/messages.py +0 -0
  37. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/prd.py +0 -0
  38. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/reports.py +0 -0
  39. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/runs.py +0 -0
  40. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/sessions.py +0 -0
  41. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/specs.py +0 -0
  42. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/tasks.py +0 -0
  43. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/workflow_runs.py +0 -0
  44. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/api/workflows.py +0 -0
  45. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/config/__init__.py +0 -0
  46. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/config/models.py +0 -0
  47. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/config/platform.py +0 -0
  48. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/config/settings.py +0 -0
  49. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/evidence.py +0 -0
  50. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/evidence_assets.py +0 -0
  51. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/executor/base.py +0 -0
  52. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/executor/claude.py +0 -0
  53. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/executor/stream.py +0 -0
  54. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/git/__init__.py +0 -0
  55. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/git/commit.py +0 -0
  56. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/git_ops.py +0 -0
  57. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/handlers/__init__.py +0 -0
  58. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/handlers/prd.py +0 -0
  59. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/integration.py +0 -0
  60. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/prompt/__init__.py +0 -0
  61. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/prompt/builder.py +0 -0
  62. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/prompt/templates.py +0 -0
  63. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  64. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/py.typed +0 -0
  65. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/retry.py +0 -0
  66. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/__init__.py +0 -0
  67. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/claude_setup.py +0 -0
  68. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/repo_setup.py +0 -0
  69. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  70. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  71. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/settings.json +0 -0
  72. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  73. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  74. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  75. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  76. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  77. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  78. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  79. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  80. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  81. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  82. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  83. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  84. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/update_check.py +0 -0
  85. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/version.py +0 -0
  86. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workflow/__init__.py +0 -0
  87. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workflow/context.py +0 -0
  88. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workflow/executor.py +0 -0
  89. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workflow/memory.py +0 -0
  90. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workspace/__init__.py +0 -0
  91. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workspace/preparation.py +0 -0
  92. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workspace/project_manager.py +0 -0
  93. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  94. {steerdev-1.1.43 → steerdev-1.1.45}/src/steerdev_agent/worktree.py +0 -0
  95. {steerdev-1.1.43 → steerdev-1.1.45}/tests/__init__.py +0 -0
  96. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_agent_loop.py +0 -0
  97. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_agent_loop_extended.py +0 -0
  98. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_agents_api.py +0 -0
  99. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_api_client.py +0 -0
  100. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_claude_executor.py +0 -0
  101. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_claude_setup.py +0 -0
  102. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_client_methods.py +0 -0
  103. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_commands_api.py +0 -0
  104. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_config.py +0 -0
  105. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_config_extended.py +0 -0
  106. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_conflict_mitigation.py +0 -0
  107. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_context_search.py +0 -0
  108. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_executor.py +0 -0
  109. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_git_commit.py +0 -0
  110. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_git_ops.py +0 -0
  111. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_hooks_post_tool_use.py +0 -0
  112. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_platform_config.py +0 -0
  113. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_preparation.py +0 -0
  114. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_preparation_provenance.py +0 -0
  115. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_prompt.py +0 -0
  116. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_reports_client.py +0 -0
  117. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_resolve_max_tasks.py +0 -0
  118. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_retry.py +0 -0
  119. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_runner_boundary_commit.py +0 -0
  120. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_runner_merge_modes.py +0 -0
  121. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_runner_worktrees.py +0 -0
  122. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_stream_parser.py +0 -0
  123. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_tasks.py +0 -0
  124. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_tasks_update_branch.py +0 -0
  125. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_version.py +0 -0
  126. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_wave_scoped_fetch.py +0 -0
  127. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workflow_context.py +0 -0
  128. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workflow_memory.py +0 -0
  129. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workflow_prompt_template.py +0 -0
  130. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workflow_runs_api.py +0 -0
  131. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workspace.py +0 -0
  132. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_workspace_extended.py +0 -0
  133. {steerdev-1.1.43 → steerdev-1.1.45}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.43
3
+ Version: 1.1.45
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.43"
3
+ version = "1.1.45"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1365,7 +1365,11 @@ def git_pr(
1365
1365
  steerdev git pr --title "Add auth" --body "Implements JWT auth"
1366
1366
  steerdev git pr --title "Add auth" --task-id abc123...
1367
1367
  """
1368
- import subprocess
1368
+ from steerdev_agent.git.pr import (
1369
+ STEERDEV_SIGNATURE,
1370
+ create_pr,
1371
+ resolve_task_base_branch,
1372
+ )
1369
1373
 
1370
1374
  # Build PR body
1371
1375
  pr_body = body or ""
@@ -1374,49 +1378,23 @@ def git_pr(
1374
1378
  pr_body += task_ref
1375
1379
 
1376
1380
  # Add steerdev signature
1377
- pr_body += "\n\n---\nCreated with [steerdev](https://steerdev.com)"
1381
+ pr_body += STEERDEV_SIGNATURE
1378
1382
 
1379
1383
  # Prefer the persisted base branch so the PR targets the correct base.
1380
- from steerdev_agent.api.tasks import TasksClient
1381
-
1382
- base_branch: str | None = None
1383
- if task_id:
1384
- try:
1385
- with TasksClient() as pr_client:
1386
- if pr_client.check_api_key():
1387
- task = pr_client.get_task(task_id)
1388
- if task:
1389
- base_branch = task.get("base_branch")
1390
- except Exception: # fall back to gh default base
1391
- base_branch = None
1392
-
1393
- try:
1394
- # Build the gh pr create command
1395
- cmd = ["gh", "pr", "create", "--title", title, "--body", pr_body]
1396
- if base_branch:
1397
- cmd += ["--base", base_branch]
1398
- if draft:
1399
- cmd.append("--draft")
1400
-
1401
- result = subprocess.run(cmd, capture_output=True, text=True)
1384
+ base_branch = resolve_task_base_branch(task_id)
1402
1385
 
1403
- if result.returncode != 0:
1404
- console.print(f"[red]Error creating PR: {result.stderr}[/red]")
1405
- raise typer.Exit(1)
1386
+ result = create_pr(title, pr_body, base_branch=base_branch, draft=draft)
1387
+ if not result.created:
1388
+ console.print(f"[red]Error creating PR: {result.error}[/red]")
1389
+ raise typer.Exit(1)
1406
1390
 
1407
- # Extract PR URL from output
1408
- pr_url = result.stdout.strip()
1409
- console.print(
1410
- Panel(
1411
- f"[bold green]Pull request created[/bold green]\n\nTitle: {title}\nURL: {pr_url}",
1412
- title="Success",
1413
- border_style="green",
1414
- )
1391
+ console.print(
1392
+ Panel(
1393
+ f"[bold green]Pull request created[/bold green]\n\nTitle: {title}\nURL: {result.url}",
1394
+ title="Success",
1395
+ border_style="green",
1415
1396
  )
1416
-
1417
- except FileNotFoundError:
1418
- console.print("[red]Error: gh not found. Install GitHub CLI: https://cli.github.com[/red]")
1419
- raise typer.Exit(1) from None
1397
+ )
1420
1398
 
1421
1399
 
1422
1400
  @git_app.command("status")
@@ -1676,6 +1654,18 @@ def run(
1676
1654
  envvar="STEERDEV_AGENT_ID",
1677
1655
  ),
1678
1656
  ] = None,
1657
+ provider: Annotated[
1658
+ str | None,
1659
+ typer.Option(
1660
+ "--provider",
1661
+ help=(
1662
+ "AI provider / executor type to run tasks with (e.g. claude)."
1663
+ " Overrides steerdev.yaml executor.type."
1664
+ " Reads from STEERDEV_PROVIDER env var."
1665
+ ),
1666
+ envvar="STEERDEV_PROVIDER",
1667
+ ),
1668
+ ] = None,
1679
1669
  model: Annotated[
1680
1670
  str | None,
1681
1671
  typer.Option(
@@ -1837,7 +1827,11 @@ def run(
1837
1827
  steerdev run --project-id abc123 --workflow-id wf-abc123
1838
1828
  steerdev run --config custom.yaml --project-id abc123
1839
1829
  """
1840
- from steerdev_agent.runner import resolve_max_tasks, run_agent
1830
+ from steerdev_agent.runner import (
1831
+ resolve_executor_config,
1832
+ resolve_max_tasks,
1833
+ run_agent,
1834
+ )
1841
1835
 
1842
1836
  # Get config from context (loaded in app callback)
1843
1837
  config: SteerDevConfig = ctx.obj or SteerDevConfig()
@@ -1866,6 +1860,12 @@ def run(
1866
1860
  resolved_evidence_config = config.evidence.model_copy()
1867
1861
  if evidence is not None:
1868
1862
  resolved_evidence_config.enabled = evidence
1863
+ # Resolve executor config: --provider/STEERDEV_PROVIDER overrides executor.type
1864
+ try:
1865
+ resolved_executor_config = resolve_executor_config(config.executor, provider)
1866
+ except ValueError as e:
1867
+ console.print(f"[red]Error: {e}[/red]")
1868
+ raise typer.Exit(1) from e
1869
1869
 
1870
1870
  # API key: CLI > env (via envvar) > config env var
1871
1871
  resolved_api_key = api_key
@@ -1897,6 +1897,7 @@ def run(
1897
1897
  f"Project ID: {resolved_project_id}\n"
1898
1898
  f"Agent Name: {agent_name_display}\n"
1899
1899
  f"Working Directory: {working_dir or 'current'}\n"
1900
+ f"Provider: {resolved_executor_config.type}\n"
1900
1901
  f"Model: {resolved_model or 'default'}\n"
1901
1902
  f"Max Tasks: {max_tasks_display}\n"
1902
1903
  f"Timeout: {resolved_timeout}s\n"
@@ -1943,6 +1944,7 @@ def run(
1943
1944
  enable_canals=canals,
1944
1945
  worktree_config=resolved_worktree_config,
1945
1946
  evidence_config=resolved_evidence_config,
1947
+ executor_config=resolved_executor_config,
1946
1948
  force_workflow_id=resolved_workflow_id,
1947
1949
  dry_run=dry_run,
1948
1950
  retry_config=retry_config,
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
12
12
  from steerdev_agent.config.models import ExecutorConfig
13
13
 
14
14
  __all__ = [
15
+ "SUPPORTED_EXECUTOR_TYPES",
15
16
  "AgentExecutor",
16
17
  "ClaudeExecutor",
17
18
  "ExecutorFactory",
@@ -19,6 +20,10 @@ __all__ = [
19
20
  "StreamParser",
20
21
  ]
21
22
 
23
+ # Executor types ExecutorFactory can build. `steerdev run --provider` and
24
+ # steerdev.yaml's executor.type are validated against this.
25
+ SUPPORTED_EXECUTOR_TYPES: tuple[str, ...] = ("claude",)
26
+
22
27
 
23
28
  class ExecutorFactory:
24
29
  """Factory for creating agent executors based on configuration."""
@@ -62,4 +67,7 @@ class ExecutorFactory:
62
67
  extra_args=config.args or None,
63
68
  )
64
69
 
65
- raise ValueError(f"Unsupported executor type: {config.type}")
70
+ raise ValueError(
71
+ f"Unsupported executor type: {config.type} "
72
+ f"(supported: {', '.join(SUPPORTED_EXECUTOR_TYPES)})"
73
+ )
@@ -0,0 +1,192 @@
1
+ """Pull-request helpers shared by the CLI and the runner's wave PR safety net.
2
+
3
+ The wave-tasks-merge skill instructs the agent to open a PR at wave end, but
4
+ nothing guarantees the agent does. `ensure_wave_pr` is the deterministic
5
+ fallback: at wave completion the runner checks whether a PR already exists for
6
+ the wave branch and creates one if not.
7
+
8
+ All functions are best-effort and never raise — a PR failure must never break
9
+ the run. Synchronous, mirroring `git_ops` (these run once at wave end, outside
10
+ the hot path).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from loguru import logger
22
+
23
+ from steerdev_agent.git_ops import current_branch
24
+
25
+ if TYPE_CHECKING:
26
+ from pathlib import Path
27
+
28
+ from steerdev_agent.prompt.builder import WaveContext
29
+
30
+ _GH_TIMEOUT = 60 # seconds; gh talks to the GitHub API
31
+
32
+ STEERDEV_SIGNATURE = "\n\n---\nCreated with [steerdev](https://steerdev.com)"
33
+
34
+
35
+ @dataclass
36
+ class PrResult:
37
+ """Outcome of a `gh pr create` invocation."""
38
+
39
+ created: bool
40
+ url: str | None = None
41
+ error: str | None = None
42
+
43
+
44
+ def _run_gh(cwd: str | Path | None, *args: str) -> tuple[int, str, str]:
45
+ """Run gh, returning (returncode, stdout, stderr). Raises only on missing gh/timeout."""
46
+ proc = subprocess.run(
47
+ ["gh", *args],
48
+ cwd=str(cwd) if cwd else None,
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=_GH_TIMEOUT,
52
+ )
53
+ return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()
54
+
55
+
56
+ def find_existing_pr(cwd: str | Path | None, branch: str) -> str:
57
+ """Check whether a PR (any state) already exists for *branch*.
58
+
59
+ Returns one of:
60
+ "exists", "none", "unknown" (gh missing or the lookup failed).
61
+ """
62
+ try:
63
+ if shutil.which("gh") is None:
64
+ logger.warning("find_existing_pr: gh not found")
65
+ return "unknown"
66
+ rc, out, err = _run_gh(
67
+ cwd, "pr", "list", "--head", branch, "--state", "all", "--json", "number,state"
68
+ )
69
+ if rc != 0:
70
+ logger.warning(f"find_existing_pr: gh pr list failed (non-fatal): {err}")
71
+ return "unknown"
72
+ return "exists" if json.loads(out or "[]") else "none"
73
+ except Exception as exc: # broad catch intentional: durability must never break the run
74
+ logger.warning(f"find_existing_pr: unexpected error (non-fatal): {exc}")
75
+ return "unknown"
76
+
77
+
78
+ def create_pr(
79
+ title: str,
80
+ body: str,
81
+ *,
82
+ cwd: str | Path | None = None,
83
+ base_branch: str | None = None,
84
+ draft: bool = False,
85
+ ) -> PrResult:
86
+ """Create a PR for the current branch via `gh pr create`. Never raises."""
87
+ try:
88
+ cmd = ["pr", "create", "--title", title, "--body", body]
89
+ if base_branch:
90
+ cmd += ["--base", base_branch]
91
+ if draft:
92
+ cmd.append("--draft")
93
+
94
+ rc, out, err = _run_gh(cwd, *cmd)
95
+ if rc != 0:
96
+ return PrResult(created=False, error=err or out)
97
+ return PrResult(created=True, url=out)
98
+ except FileNotFoundError:
99
+ return PrResult(
100
+ created=False, error="gh not found. Install GitHub CLI: https://cli.github.com"
101
+ )
102
+ except Exception as exc: # broad catch intentional: durability must never break the run
103
+ return PrResult(created=False, error=str(exc))
104
+
105
+
106
+ def resolve_task_base_branch(task_id: str | None, api_key: str | None = None) -> str | None:
107
+ """Resolve the persisted PR base from a task's `base_branch`, if available.
108
+
109
+ For a stacked wave this is the predecessor wave's branch. Falls back to
110
+ None (gh's default base) on any failure.
111
+ """
112
+ if not task_id:
113
+ return None
114
+ try:
115
+ from steerdev_agent.api.tasks import TasksClient
116
+
117
+ with TasksClient(api_key=api_key) as client:
118
+ if not client.check_api_key():
119
+ return None
120
+ task = client.get_task(task_id)
121
+ return task.get("base_branch") if task else None
122
+ except Exception: # fall back to gh default base
123
+ logger.debug("resolve_task_base_branch: lookup failed", exc_info=True)
124
+ return None
125
+
126
+
127
+ def build_wave_pr_title(wave_context: WaveContext) -> str:
128
+ """Build the fallback PR title for a completed wave."""
129
+ name = (wave_context.wave_name or "").strip()
130
+ if name and name != f"wave-{wave_context.wave_number}":
131
+ return f"Wave {wave_context.wave_number}: {name}"
132
+ return f"Wave {wave_context.wave_number}"
133
+
134
+
135
+ def build_wave_pr_body(completed_tasks: list[dict[str, Any]]) -> str:
136
+ """Build the fallback PR body: one bullet per completed task, plus signature."""
137
+ lines = ["## Completed tasks", ""]
138
+ for task in completed_tasks:
139
+ task_id = task.get("id", "unknown")
140
+ short = task_id[:8] if len(task_id) > 8 else task_id
141
+ title = (task.get("title") or "").strip() or "(untitled)"
142
+ lines.append(f"- `{short}` {title}")
143
+ if not completed_tasks:
144
+ lines.append("- (no task summary available)")
145
+ return "\n".join(lines) + STEERDEV_SIGNATURE
146
+
147
+
148
+ def ensure_wave_pr(
149
+ cwd: str | Path,
150
+ wave_context: WaveContext,
151
+ completed_tasks: list[dict[str, Any]],
152
+ api_key: str | None = None,
153
+ ) -> str:
154
+ """Create the wave PR if the agent didn't. Best-effort; never raises.
155
+
156
+ Returns one of:
157
+ "created", "already_exists", "skipped_detached", "failed".
158
+ """
159
+ try:
160
+ branch = current_branch(cwd)
161
+ if branch is None:
162
+ logger.info("ensure_wave_pr: detached HEAD, skipping")
163
+ return "skipped_detached"
164
+
165
+ existing = find_existing_pr(cwd, branch)
166
+ if existing == "exists":
167
+ logger.info(f"ensure_wave_pr: PR already exists for {branch}, skipping")
168
+ return "already_exists"
169
+ if existing == "unknown":
170
+ logger.warning("ensure_wave_pr: could not check for existing PR, skipping")
171
+ return "failed"
172
+
173
+ last_task = completed_tasks[-1] if completed_tasks else None
174
+ base_branch = resolve_task_base_branch(
175
+ last_task.get("id") if last_task else None, api_key=api_key
176
+ )
177
+
178
+ result = create_pr(
179
+ build_wave_pr_title(wave_context),
180
+ build_wave_pr_body(completed_tasks),
181
+ cwd=cwd,
182
+ base_branch=base_branch,
183
+ )
184
+ if not result.created:
185
+ logger.warning(f"ensure_wave_pr: PR creation failed (non-fatal): {result.error}")
186
+ return "failed"
187
+
188
+ logger.info(f"ensure_wave_pr: created PR for {branch}: {result.url}")
189
+ return "created"
190
+ except Exception as exc: # broad catch intentional: durability must never break the run
191
+ logger.warning(f"ensure_wave_pr: unexpected error (non-fatal): {exc}")
192
+ return "failed"
@@ -26,10 +26,11 @@ from steerdev_agent.config.models import (
26
26
  RetryConfig,
27
27
  WorktreeConfig,
28
28
  )
29
- from steerdev_agent.executor import ExecutorFactory
29
+ from steerdev_agent.executor import SUPPORTED_EXECUTOR_TYPES, ExecutorFactory
30
30
  from steerdev_agent.executor.base import AgentExecutor, EventType, StreamEvent
31
31
  from steerdev_agent.executor.claude import ClaudeExecutorError
32
32
  from steerdev_agent.git.commit import commit_task_boundary
33
+ from steerdev_agent.git.pr import ensure_wave_pr
33
34
  from steerdev_agent.git_ops import checkpoint_push
34
35
  from steerdev_agent.prompt.builder import (
35
36
  ProjectContext,
@@ -164,6 +165,29 @@ def resolve_max_tasks(max_tasks: int | None, wave_id: str | None) -> int:
164
165
  return 0 if wave_id else 1
165
166
 
166
167
 
168
+ def resolve_executor_config(
169
+ base: ExecutorConfig,
170
+ provider: str | None,
171
+ ) -> ExecutorConfig:
172
+ """Resolve the effective executor config for a run.
173
+
174
+ - `provider` (from --provider / STEERDEV_PROVIDER) overrides the config
175
+ file's executor.type; None keeps it.
176
+ - The resolved type is validated against SUPPORTED_EXECUTOR_TYPES so an
177
+ unsupported provider fails fast (before task claiming), not deep inside
178
+ ExecutorFactory mid-run.
179
+ """
180
+ resolved = base.model_copy()
181
+ if provider is not None:
182
+ resolved.type = provider
183
+ if resolved.type not in SUPPORTED_EXECUTOR_TYPES:
184
+ raise ValueError(
185
+ f"Unsupported provider: {resolved.type} "
186
+ f"(supported: {', '.join(SUPPORTED_EXECUTOR_TYPES)})"
187
+ )
188
+ return resolved
189
+
190
+
167
191
  def resolve_merge_flow(
168
192
  waves_enabled: bool,
169
193
  canals_enabled: bool,
@@ -248,6 +272,11 @@ def compute_worktree_name(task_id: str, wave_context: WaveContext | None = None)
248
272
  return f"task-{task_short}"
249
273
 
250
274
 
275
+ def wave_identity(wave_context: WaveContext) -> str:
276
+ """Stable identity for a wave; matches the wave branch/worktree naming."""
277
+ return wave_context.wave_name or f"wave-{wave_context.wave_number}"
278
+
279
+
251
280
  def build_workflow_task_context(
252
281
  task: dict[str, Any],
253
282
  wave_context: WaveContext | None,
@@ -409,6 +438,11 @@ class Runner:
409
438
  self._tasks_failed = 0
410
439
  self._events_sent = 0
411
440
 
441
+ # Wave PR safety-net state (see _maybe_create_wave_pr)
442
+ self._last_wave_ctx: WaveContext | None = None
443
+ self._wave_completed_tasks: list[dict[str, Any]] = []
444
+ self._wave_working_dir: str | None = None
445
+
412
446
  # Components
413
447
  self._sessions_client: SessionsClient | None = None
414
448
  self._events_client: EventsClient | None = None
@@ -983,6 +1017,7 @@ class Runner:
983
1017
  "exit_code": exit_code,
984
1018
  "agent_session_id": agent_session_id,
985
1019
  "events_sent": self._events_sent,
1020
+ "working_dir": str(effective_working_dir),
986
1021
  }
987
1022
 
988
1023
  except (KeyboardInterrupt, asyncio.CancelledError):
@@ -1140,6 +1175,7 @@ class Runner:
1140
1175
  "phases_completed": phases_completed,
1141
1176
  "phases_failed": phases_failed,
1142
1177
  "total_phases": total_phases,
1178
+ "working_dir": str(effective_working_dir),
1143
1179
  }
1144
1180
 
1145
1181
  except Exception as e:
@@ -1267,8 +1303,20 @@ class Runner:
1267
1303
  console.print("[yellow]No tasks available[/yellow]")
1268
1304
  else:
1269
1305
  console.print("[yellow]No more tasks available[/yellow]")
1306
+ # No more wave tasks → the wave is done; make sure its PR exists.
1307
+ self._maybe_create_wave_pr()
1270
1308
  break
1271
1309
 
1310
+ # Moving on to a different wave means the previous one
1311
+ # completed — flush its PR safety net before tracking the new
1312
+ # wave.
1313
+ if (
1314
+ wave_ctx is not None
1315
+ and self._last_wave_ctx is not None
1316
+ and wave_identity(wave_ctx) != wave_identity(self._last_wave_ctx)
1317
+ ):
1318
+ self._maybe_create_wave_pr()
1319
+
1272
1320
  # Resolve workflow: force override > API suggestion > none
1273
1321
  per_task_workflow_id = self.workflow_id or suggested_workflow_id
1274
1322
 
@@ -1296,6 +1344,14 @@ class Runner:
1296
1344
  if result.get("success"):
1297
1345
  self._tasks_succeeded += 1
1298
1346
  console.print("[green]Task completed successfully[/green]")
1347
+ if wave_ctx is not None:
1348
+ # Track wave progress for the PR safety net.
1349
+ self._last_wave_ctx = wave_ctx
1350
+ self._wave_completed_tasks.append(
1351
+ {"id": task.get("id"), "title": task.get("title")}
1352
+ )
1353
+ if result.get("working_dir"):
1354
+ self._wave_working_dir = result["working_dir"]
1299
1355
  else:
1300
1356
  self._tasks_failed += 1
1301
1357
  console.print(f"[red]Task failed: {result.get('error', 'Unknown')}[/red]")
@@ -1388,6 +1444,37 @@ class Runner:
1388
1444
  "error": str(run_error) if run_error else None,
1389
1445
  }
1390
1446
 
1447
+ def _maybe_create_wave_pr(self) -> None:
1448
+ """Wave PR safety net: create the wave PR if the agent didn't.
1449
+
1450
+ Fires when a wave finishes — either no more wave tasks come back, or
1451
+ the run moves on to a different wave. The wave-tasks-merge skill
1452
+ normally has the agent create a richer PR; this only acts when no PR
1453
+ exists for the wave branch yet. Best-effort: never raises, never fails
1454
+ the run. Always resets the tracked wave state.
1455
+ """
1456
+ wave_ctx = self._last_wave_ctx
1457
+ try:
1458
+ if wave_ctx is None or self.dry_run:
1459
+ return
1460
+ working_dir = self._wave_working_dir or str(self.working_directory)
1461
+ status = ensure_wave_pr(
1462
+ working_dir,
1463
+ wave_ctx,
1464
+ self._wave_completed_tasks,
1465
+ api_key=self._api_key,
1466
+ )
1467
+ if status == "created":
1468
+ console.print("[green]Wave PR created (safety net)[/green]")
1469
+ else:
1470
+ logger.info(f"Wave PR safety net: {status}")
1471
+ except Exception: # broad catch intentional: safety net must never break the run
1472
+ logger.warning("Wave PR safety net failed (non-fatal)", exc_info=True)
1473
+ finally:
1474
+ self._last_wave_ctx = None
1475
+ self._wave_completed_tasks = []
1476
+ self._wave_working_dir = None
1477
+
1391
1478
  async def resume(
1392
1479
  self,
1393
1480
  session_id: str,