steerdev 1.1.28__tar.gz → 1.1.31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {steerdev-1.1.28 → steerdev-1.1.31}/PKG-INFO +1 -1
  2. {steerdev-1.1.28 → steerdev-1.1.31}/pyproject.toml +1 -1
  3. steerdev-1.1.31/src/steerdev_agent/git/__init__.py +1 -0
  4. steerdev-1.1.31/src/steerdev_agent/git/commit.py +96 -0
  5. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/prompt/builder.py +4 -0
  6. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/runner.py +48 -0
  7. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +37 -24
  8. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workspace/preparation.py +78 -25
  9. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/worktree.py +11 -1
  10. steerdev-1.1.31/tests/test_git_commit.py +127 -0
  11. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_preparation.py +135 -0
  12. steerdev-1.1.31/tests/test_runner_boundary_commit.py +74 -0
  13. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_runner_merge_modes.py +25 -0
  14. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_runner_worktrees.py +17 -1
  15. {steerdev-1.1.28 → steerdev-1.1.31}/.github/workflows/pre-commit.yml +0 -0
  16. {steerdev-1.1.28 → steerdev-1.1.31}/.github/workflows/publish.yml +0 -0
  17. {steerdev-1.1.28 → steerdev-1.1.31}/.gitignore +0 -0
  18. {steerdev-1.1.28 → steerdev-1.1.31}/.pre-commit-config.yaml +0 -0
  19. {steerdev-1.1.28 → steerdev-1.1.31}/AGENTS.md +0 -0
  20. {steerdev-1.1.28 → steerdev-1.1.31}/CLAUDE.md +0 -0
  21. {steerdev-1.1.28 → steerdev-1.1.31}/README.md +0 -0
  22. {steerdev-1.1.28 → steerdev-1.1.31}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  23. {steerdev-1.1.28 → steerdev-1.1.31}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  24. {steerdev-1.1.28 → steerdev-1.1.31}/scripts/pre-commit-version-bump.sh +0 -0
  25. {steerdev-1.1.28 → steerdev-1.1.31}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  26. {steerdev-1.1.28 → steerdev-1.1.31}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  27. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/__init__.py +0 -0
  28. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/agent_loop.py +0 -0
  29. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/__init__.py +0 -0
  30. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/activity.py +0 -0
  31. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/agents.py +0 -0
  32. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/canals.py +0 -0
  33. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/client.py +0 -0
  34. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/commands.py +0 -0
  35. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/configs.py +0 -0
  36. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/context.py +0 -0
  37. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/events.py +0 -0
  38. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/hooks.py +0 -0
  39. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/implementation_plan.py +0 -0
  40. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/merger.py +0 -0
  41. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/messages.py +0 -0
  42. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/prd.py +0 -0
  43. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/reports.py +0 -0
  44. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/runs.py +0 -0
  45. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/sessions.py +0 -0
  46. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/specs.py +0 -0
  47. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/tasks.py +0 -0
  48. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/workflow_runs.py +0 -0
  49. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/api/workflows.py +0 -0
  50. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/cli.py +0 -0
  51. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/config/__init__.py +0 -0
  52. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/config/models.py +0 -0
  53. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/config/platform.py +0 -0
  54. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/config/settings.py +0 -0
  55. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/evidence.py +0 -0
  56. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/evidence_assets.py +0 -0
  57. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/executor/__init__.py +0 -0
  58. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/executor/base.py +0 -0
  59. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/executor/claude.py +0 -0
  60. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/executor/stream.py +0 -0
  61. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/git_ops.py +0 -0
  62. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/handlers/__init__.py +0 -0
  63. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/handlers/prd.py +0 -0
  64. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/integration.py +0 -0
  65. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/prompt/__init__.py +0 -0
  66. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/prompt/templates.py +0 -0
  67. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  68. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/py.typed +0 -0
  69. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/retry.py +0 -0
  70. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/__init__.py +0 -0
  71. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/claude_setup.py +0 -0
  72. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/repo_setup.py +0 -0
  73. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  74. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  75. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/settings.json +0 -0
  76. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  77. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  78. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  79. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  80. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  81. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  82. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  83. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  84. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  85. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  86. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  87. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/update_check.py +0 -0
  88. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/version.py +0 -0
  89. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workflow/__init__.py +0 -0
  90. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workflow/context.py +0 -0
  91. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workflow/executor.py +0 -0
  92. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workflow/memory.py +0 -0
  93. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workspace/__init__.py +0 -0
  94. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workspace/project_manager.py +0 -0
  95. {steerdev-1.1.28 → steerdev-1.1.31}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  96. {steerdev-1.1.28 → steerdev-1.1.31}/tests/__init__.py +0 -0
  97. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_agent_loop.py +0 -0
  98. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_agent_loop_extended.py +0 -0
  99. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_agents_api.py +0 -0
  100. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_api_client.py +0 -0
  101. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_claude_executor.py +0 -0
  102. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_claude_setup.py +0 -0
  103. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_client_methods.py +0 -0
  104. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_commands_api.py +0 -0
  105. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_config.py +0 -0
  106. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_config_extended.py +0 -0
  107. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_conflict_mitigation.py +0 -0
  108. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_context_search.py +0 -0
  109. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_executor.py +0 -0
  110. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_git_ops.py +0 -0
  111. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_hooks_post_tool_use.py +0 -0
  112. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_platform_config.py +0 -0
  113. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_preparation_provenance.py +0 -0
  114. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_prompt.py +0 -0
  115. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_reports_client.py +0 -0
  116. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_resolve_max_tasks.py +0 -0
  117. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_retry.py +0 -0
  118. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_stream_parser.py +0 -0
  119. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_tasks.py +0 -0
  120. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_tasks_update_branch.py +0 -0
  121. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_version.py +0 -0
  122. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_wave_scoped_fetch.py +0 -0
  123. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workflow_context.py +0 -0
  124. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workflow_memory.py +0 -0
  125. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workflow_prompt_template.py +0 -0
  126. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workflow_runs_api.py +0 -0
  127. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workspace.py +0 -0
  128. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_workspace_extended.py +0 -0
  129. {steerdev-1.1.28 → steerdev-1.1.31}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.28
3
+ Version: 1.1.31
4
4
  Summary: Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting
5
5
  Project-URL: Homepage, https://github.com/pentoai/steerdev-agent
6
6
  Project-URL: Repository, https://github.com/pentoai/steerdev-agent
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerdev"
3
- version = "1.1.28"
3
+ version = "1.1.31"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1 @@
1
+ """Git helpers for the steerdev runtime."""
@@ -0,0 +1,96 @@
1
+ """Deterministic per-task boundary commit for wave runs.
2
+
3
+ After each successful task in wave mode, the runner commits everything
4
+ outstanding with a task-scoped message, so the wave branch ends up with one
5
+ clean commit per task regardless of how the agent chose to commit (or didn't).
6
+
7
+ All functions are best-effort and never raise — a commit failure must never
8
+ break the run. Async, mirroring `workspace.preparation._run_git`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from loguru import logger
17
+
18
+ from steerdev_agent.git_ops import PROTECTED_BRANCHES
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+
24
+ def build_task_commit_message(task: dict[str, Any]) -> str:
25
+ """Build a task-scoped commit message.
26
+
27
+ Prefix precedence mirrors `worktree.compute_branch_name`:
28
+ - external ticket ID (`linear_identifier`, then `external_id`) → `[PROJ-123]`
29
+ - otherwise the first 8 chars of the internal id → `[task:abc12345]`
30
+
31
+ The task title is appended when present.
32
+ """
33
+ external_id = task.get("linear_identifier") or task.get("external_id")
34
+ if external_id:
35
+ prefix = f"[{external_id}]"
36
+ else:
37
+ task_id = task.get("id", "unknown")
38
+ short = task_id[:8] if len(task_id) > 8 else task_id
39
+ prefix = f"[task:{short}]"
40
+
41
+ title = (task.get("title") or "").strip()
42
+ return f"{prefix} {title}" if title else prefix
43
+
44
+
45
+ async def _run_git(cwd: str | Path, *args: str) -> tuple[int, str]:
46
+ """Run git, returning (returncode, stdout+stderr). Never raises for git errors."""
47
+ proc = await asyncio.create_subprocess_exec(
48
+ "git",
49
+ *args,
50
+ cwd=str(cwd),
51
+ stdout=asyncio.subprocess.PIPE,
52
+ stderr=asyncio.subprocess.PIPE,
53
+ )
54
+ stdout_bytes, stderr_bytes = await proc.communicate()
55
+ out = (stdout_bytes.decode("utf-8", errors="replace") or "").strip()
56
+ err = (stderr_bytes.decode("utf-8", errors="replace") or "").strip()
57
+ return proc.returncode or 0, out or err
58
+
59
+
60
+ async def commit_task_boundary(cwd: str | Path, task: dict[str, Any]) -> str:
61
+ """Stage and commit everything outstanding as one task-scoped commit.
62
+
63
+ Best-effort; never raises. Returns one of:
64
+ "committed", "nothing_to_commit", "skipped_protected", "failed".
65
+ """
66
+ try:
67
+ # Resolve current branch; skip protected branches and detached/non-repo.
68
+ rc, branch = await _run_git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
69
+ if rc != 0 or not branch or branch == "HEAD":
70
+ logger.info("commit_task_boundary: not on a branch, skipping")
71
+ return "failed"
72
+ if branch in PROTECTED_BRANCHES:
73
+ logger.info(f"commit_task_boundary: protected branch {branch}, skipping")
74
+ return "skipped_protected"
75
+
76
+ rc, _ = await _run_git(cwd, "add", "-A")
77
+ if rc != 0:
78
+ logger.warning("commit_task_boundary: git add failed (non-fatal)")
79
+ return "failed"
80
+
81
+ # Anything staged? `diff --cached --quiet` exits 1 when there are changes.
82
+ rc, _ = await _run_git(cwd, "diff", "--cached", "--quiet")
83
+ if rc == 0:
84
+ return "nothing_to_commit"
85
+
86
+ message = build_task_commit_message(task)
87
+ rc, out = await _run_git(cwd, "commit", "-m", message)
88
+ if rc != 0:
89
+ logger.warning(f"commit_task_boundary: commit failed (non-fatal): {out}")
90
+ return "failed"
91
+
92
+ logger.info(f"commit_task_boundary: committed on {branch}: {message}")
93
+ return "committed"
94
+ except Exception as exc: # broad catch intentional: durability must never break the run
95
+ logger.warning(f"commit_task_boundary: unexpected error (non-fatal): {exc}")
96
+ return "failed"
@@ -28,6 +28,10 @@ class WaveContext(BaseModel):
28
28
  wave_description: str = Field(default="", description="Description of this wave")
29
29
  wave_tasks_summary: str = Field(default="", description="Summary of tasks in this wave")
30
30
  completed_waves_summary: str = Field(default="", description="Summary of completed waves")
31
+ predecessor_branch: str | None = Field(
32
+ default=None,
33
+ description="Branch of the previous completed wave to stack this wave on",
34
+ )
31
35
 
32
36
 
33
37
  class TaskContext(BaseModel):
@@ -29,6 +29,8 @@ from steerdev_agent.config.models import (
29
29
  from steerdev_agent.executor import ExecutorFactory
30
30
  from steerdev_agent.executor.base import AgentExecutor, EventType, StreamEvent
31
31
  from steerdev_agent.executor.claude import ClaudeExecutorError
32
+ from steerdev_agent.git.commit import commit_task_boundary
33
+ from steerdev_agent.git_ops import checkpoint_push
32
34
  from steerdev_agent.prompt.builder import (
33
35
  ProjectContext,
34
36
  PromptBuilder,
@@ -108,6 +110,15 @@ def build_wave_context(wave_response: dict[str, Any]) -> WaveContext | None:
108
110
  ]
109
111
  completed_summary = "\n".join(completed_lines) if completed_lines else ""
110
112
 
113
+ # Stacked-wave base: the server sends the previous completed wave's branch
114
+ # NAME (never a number — the wave name IS the branch). A blank/absent name
115
+ # means no valid predecessor, so we leave the base unset and cut from default.
116
+ predecessor_branch: str | None = None
117
+ predecessor = wave_info.get("predecessor_wave")
118
+ if predecessor:
119
+ name = (predecessor.get("name") or "").strip()
120
+ predecessor_branch = name or None
121
+
111
122
  return WaveContext(
112
123
  wave_number=wave_info.get("wave_number", 1),
113
124
  total_waves=wave_info.get("total_waves", 1),
@@ -115,6 +126,7 @@ def build_wave_context(wave_response: dict[str, Any]) -> WaveContext | None:
115
126
  wave_description=wave_info.get("description", ""),
116
127
  wave_tasks_summary=tasks_summary,
117
128
  completed_waves_summary=completed_summary,
129
+ predecessor_branch=predecessor_branch,
118
130
  )
119
131
 
120
132
 
@@ -200,6 +212,27 @@ async def _get_modified_files(working_dir: str | Path) -> list[str]:
200
212
  return []
201
213
 
202
214
 
215
+ async def maybe_commit_task_boundary(
216
+ working_dir: str | Path,
217
+ task: dict[str, Any],
218
+ wave_context: WaveContext | None,
219
+ dry_run: bool,
220
+ ) -> None:
221
+ """Commit a wave task's work at its completion boundary, then checkpoint-push.
222
+
223
+ Only fires in wave mode (single-task mode keeps the agent-driven,
224
+ one-PR-per-task flow). Best-effort: `commit_task_boundary` and
225
+ `checkpoint_push` never raise. Pushes only when a commit was actually made,
226
+ so the wave branch lands on origin for the next wave to stack on.
227
+ """
228
+ if dry_run or wave_context is None:
229
+ return
230
+
231
+ result = await commit_task_boundary(working_dir, task)
232
+ if result == "committed":
233
+ checkpoint_push(working_dir)
234
+
235
+
203
236
  def compute_worktree_name(task_id: str, wave_context: WaveContext | None = None) -> str:
204
237
  """Compute the git worktree name for a task execution.
205
238
 
@@ -924,6 +957,14 @@ class Runner:
924
957
  except Exception:
925
958
  logger.debug("Failed to report modified files", exc_info=True)
926
959
 
960
+ # Deterministic per-task commit boundary (wave mode only): commit
961
+ # everything outstanding as one task-scoped commit and checkpoint-push,
962
+ # so the wave branch has one clean commit per task and lands on origin
963
+ # for the next wave to stack on. No-op in single-task/dry-run mode.
964
+ await maybe_commit_task_boundary(
965
+ effective_working_dir, task, wave_context, self.dry_run
966
+ )
967
+
927
968
  if self._evidence_config.enabled and not self.dry_run:
928
969
  await self._submit_evidence_report(
929
970
  task=task,
@@ -1072,6 +1113,13 @@ class Runner:
1072
1113
  else:
1073
1114
  logger.warning(f"Failed to mark task {task_id} as completed")
1074
1115
 
1116
+ # Deterministic per-task commit boundary (wave mode only; successful
1117
+ # tasks only). See execute_task for rationale.
1118
+ if success:
1119
+ await maybe_commit_task_boundary(
1120
+ effective_working_dir, task, wave_context, self.dry_run
1121
+ )
1122
+
1075
1123
  if self._evidence_config.enabled and not self.dry_run:
1076
1124
  await self._submit_evidence_report(
1077
1125
  task=task,
@@ -31,15 +31,7 @@ git checkout -b wave/<wave-number>
31
31
  2. For each task in the wave:
32
32
  - Mark the task `started`
33
33
  - Implement the requested change
34
- - Commit with a task-prefixed conventional commit. **Use the external ticket ID when available** (e.g. Linear ID), falling back to the internal short ID:
35
-
36
- ```bash
37
- # With external ticket ID (preferred):
38
- git commit -m "[PROJ-123] feat: add user endpoint"
39
-
40
- # Fallback (no external ID):
41
- git commit -m "[task:abc12345] feat: add user endpoint"
42
- ```
34
+ - **Do not commit.** The steerdev runtime automatically commits each task's work at the task boundary with a task-prefixed message (`[PROJ-123] <title>`, or `[task:abc12345] <title>` when there is no external ID) and pushes the wave branch. You just implement; the runtime owns the per-task commit.
43
35
 
44
36
  3. After the wave work is ready for review, rebase onto the latest default branch:
45
37
 
@@ -77,25 +69,49 @@ git merge-tree --write-tree origin/<default-branch> HEAD
77
69
  steerdev git pr --title "Wave <N>: <description>" --body "..."
78
70
  ```
79
71
 
72
+ **Do not pass `--base`.** `steerdev git pr` sets the PR base automatically from
73
+ the task's recorded base branch. For a stacked wave (a wave that builds on a
74
+ previous wave of the same PRD) the base is the predecessor wave's branch, so the
75
+ PR shows only this wave's diff. Overriding `--base` would break the stack.
76
+
80
77
  5. Update all related tasks with the shared PR URL
81
78
  6. Log progress and completion in the root `docs/` progress files
82
79
 
83
- ## Commit Prefix Convention
80
+ ## Commit Convention (handled by the runtime)
84
81
 
85
- Use external ticket IDs in commits so GitHub integrations can link commits to issues:
82
+ You do **not** commit task work yourself. The steerdev runtime commits each task
83
+ automatically at its completion boundary, using an external-ticket-ID-prefixed
84
+ message so GitHub integrations can link commits to issues:
86
85
 
87
86
  | Has External ID? | Commit Prefix | Example |
88
87
  |---|---|---|
89
- | Yes | `[<TICKET-ID>]` | `[PROJ-123] feat: add user endpoint` |
90
- | No | `[task:<short-uuid>]` | `[task:abc12345] feat: add user endpoint` |
88
+ | Yes | `[<TICKET-ID>]` | `[PROJ-123] <task title>` |
89
+ | No | `[task:<short-uuid>]` | `[task:abc12345] <task title>` |
91
90
 
92
- Run `steerdev tasks get TASK_ID` to find the task's `linear_identifier` before committing.
91
+ It also pushes the wave branch after each commit. Your job is to implement and,
92
+ once the wave is done, rebase and open the PR (steps 3–5).
93
+
94
+ ## Stacked waves (same PRD)
95
+
96
+ Waves of one PRD are **stacked**: wave-2 is branched off wave-1, wave-3 off
97
+ wave-2, and so on. The CLI handles this for you — it cuts the wave branch off the
98
+ previous wave's branch and records that branch as the PR base. As a result:
99
+
100
+ - Each wave still gets its own PR, but its diff shows only that wave's changes.
101
+ - The PRs form a chain: `wave-1 → default`, `wave-2 → wave-1`, `wave-3 → wave-2`.
102
+ - **Do not delete a wave's branch until the next wave's PR has merged.** When
103
+ wave-1's PR merges into the default branch, GitHub automatically retargets
104
+ wave-2's PR — but only if wave-1's branch still exists.
105
+ - The first wave of a PRD (and any wave with no completed same-PRD predecessor)
106
+ is cut from the default branch as usual.
93
107
 
94
108
  ## Conventions
95
109
 
96
110
  - Use one branch per wave: `wave/<wave-number>`
111
+ - Do not commit task work — the runtime commits and pushes each task automatically
97
112
  - Do not create per-task PRs inside the wave
98
- - Keep commits traceable by including the external ticket ID (or internal ID fallback) in each commit
113
+ - Do not pass `--base` to `steerdev git pr` the base is set automatically
114
+ - Do not delete wave branches until the dependent wave's PR has merged
99
115
  - Include the shared PR URL in every related task result
100
116
  - Replace `<default-branch>` in commands above with the actual branch name from the Git Branch Configuration section
101
117
 
@@ -105,18 +121,15 @@ Run `steerdev tasks get TASK_ID` to find the task's `linear_identifier` before c
105
121
  git checkout -b wave/3
106
122
 
107
123
  steerdev tasks update task-aaa... --status started
108
- # Check: steerdev tasks get task-aaa... Linear ID: PROJ-123
109
- # ... implement task 1 ...
110
- git add .
111
- git commit -m "[PROJ-123] feat: add user endpoint"
124
+ # ... implement task 1 ... (runtime commits "[PROJ-123] <title>" at the boundary)
112
125
 
113
126
  steerdev tasks update task-bbb... --status started
114
- # Check: steerdev tasks get task-bbb... Linear ID: PROJ-124
115
- # ... implement task 2 ...
116
- git add .
117
- git commit -m "[PROJ-124] fix: validate email format"
127
+ # ... implement task 2 ... (runtime commits "[PROJ-124] <title>" at the boundary)
118
128
 
119
- git push -u origin HEAD
129
+ # Wave done: rebase onto the default branch (see step 3), then open one PR.
130
+ git fetch origin <default-branch>
131
+ git rebase origin/<default-branch>
132
+ git push --force-with-lease origin HEAD
120
133
  steerdev git pr --title "Wave 3: User management improvements" --body "..."
121
134
  steerdev tasks update task-aaa... --result "PR: https://github.com/org/repo/pull/42"
122
135
  steerdev tasks update task-bbb... --result "PR: https://github.com/org/repo/pull/42"
@@ -347,38 +347,91 @@ class WorkspacePreparation:
347
347
  # 4. Fetch from origin
348
348
  await _run_git("fetch", "origin", cwd=cwd, check=False)
349
349
 
350
- # 5. Sync to default branch
351
- rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
352
- if rc != 0:
353
- return PreparationResult(
354
- success=False,
355
- branch_name=target_branch,
356
- working_directory=cwd,
357
- default_branch=default_branch,
358
- is_worktree=False,
359
- error=f"Failed to checkout {default_branch}. Does the branch exist?",
360
- )
361
-
362
- # Try fast-forward merge
363
- rc, _, _ = await _run_git(
364
- "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
365
- )
366
- if rc != 0:
367
- # Fallback: hard reset to origin
368
- logger.warning(f"Fast-forward merge failed for {default_branch}, resetting to origin")
369
- await _run_git("reset", "--hard", f"origin/{default_branch}", cwd=cwd, check=False)
370
- warnings.append(
371
- f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
350
+ # 5. Resolve the base this wave stacks on. For a new wave with a
351
+ # predecessor (stacked-wave mode), branch directly off the predecessor's
352
+ # remote branch so the wave physically contains the previous wave's work.
353
+ # If that branch is missing from origin (never pushed / deleted), the
354
+ # chain is broken — fall back to the default branch and surface a loud
355
+ # warning rather than silently building on the wrong base.
356
+ predecessor_branch = wave_context.predecessor_branch if wave_context else None
357
+ effective_base = default_branch
358
+ if predecessor_branch:
359
+ rc, _, _ = await _run_git(
360
+ "rev-parse", "--verify", f"origin/{predecessor_branch}", cwd=cwd, check=False
372
361
  )
362
+ if rc == 0:
363
+ effective_base = predecessor_branch
364
+ else:
365
+ logger.warning(
366
+ f"Predecessor branch origin/{predecessor_branch} missing — "
367
+ f"stacked-wave chain broken; cutting {target_branch} from "
368
+ f"{default_branch} instead"
369
+ )
370
+ warnings.append(
371
+ f"Predecessor branch '{predecessor_branch}' not found on origin; "
372
+ f"stacked-wave chain broken — cut from '{default_branch}'. This wave "
373
+ f"may not contain the previous wave's work."
374
+ )
373
375
 
374
- # 6. Create or checkout target branch
376
+ # 6. Create or checkout the target (wave) branch.
375
377
  rc, _, _ = await _run_git("rev-parse", "--verify", target_branch, cwd=cwd, check=False)
376
378
  branch_exists = rc == 0
377
379
 
378
380
  if branch_exists:
379
- await _run_git("checkout", target_branch, cwd=cwd)
381
+ # Branch already present locally (e.g. a continuing wave on a fresh
382
+ # clone): just switch to it.
383
+ rc, _, _ = await _run_git("checkout", target_branch, cwd=cwd, check=False)
384
+ if rc != 0:
385
+ return PreparationResult(
386
+ success=False,
387
+ branch_name=target_branch,
388
+ working_directory=cwd,
389
+ default_branch=default_branch,
390
+ is_worktree=False,
391
+ error=f"Failed to checkout existing branch {target_branch}",
392
+ )
380
393
  logger.info(f"Switched to existing branch: {target_branch}")
394
+ elif effective_base != default_branch:
395
+ # Stacked cut: branch straight off the predecessor's remote branch.
396
+ # No default-branch sync — the predecessor already carries the work.
397
+ rc, _, _ = await _run_git(
398
+ "checkout", "-b", target_branch, f"origin/{effective_base}", cwd=cwd, check=False
399
+ )
400
+ if rc != 0:
401
+ return PreparationResult(
402
+ success=False,
403
+ branch_name=target_branch,
404
+ working_directory=cwd,
405
+ default_branch=default_branch,
406
+ is_worktree=False,
407
+ error=f"Failed to cut {target_branch} from origin/{effective_base}",
408
+ )
409
+ logger.info(f"Created {target_branch} stacked on origin/{effective_base}")
381
410
  else:
411
+ # Default cut: sync to the default branch, then branch off it.
412
+ rc, _, _ = await _run_git("checkout", default_branch, cwd=cwd, check=False)
413
+ if rc != 0:
414
+ return PreparationResult(
415
+ success=False,
416
+ branch_name=target_branch,
417
+ working_directory=cwd,
418
+ default_branch=default_branch,
419
+ is_worktree=False,
420
+ error=f"Failed to checkout {default_branch}. Does the branch exist?",
421
+ )
422
+
423
+ rc, _, _ = await _run_git(
424
+ "merge", "--ff-only", f"origin/{default_branch}", cwd=cwd, check=False
425
+ )
426
+ if rc != 0:
427
+ logger.warning(
428
+ f"Fast-forward merge failed for {default_branch}, resetting to origin"
429
+ )
430
+ await _run_git("reset", "--hard", f"origin/{default_branch}", cwd=cwd, check=False)
431
+ warnings.append(
432
+ f"Fast-forward failed, reset {default_branch} to origin/{default_branch}"
433
+ )
434
+
382
435
  await _run_git("checkout", "-b", target_branch, cwd=cwd)
383
436
  logger.info(f"Created new branch: {target_branch}")
384
437
 
@@ -395,7 +448,7 @@ class WorkspacePreparation:
395
448
  )
396
449
 
397
450
  await persist_branch_provenance(
398
- task_id=task_id, branch=target_branch, base_branch=default_branch
451
+ task_id=task_id, branch=target_branch, base_branch=effective_base
399
452
  )
400
453
 
401
454
  return PreparationResult(
@@ -206,6 +206,16 @@ class WorktrunkClient:
206
206
  return None
207
207
 
208
208
 
209
+ def wave_branch_name(name: str | None, wave_number: int) -> str:
210
+ """The single source of truth for a wave's git branch name.
211
+
212
+ A wave branch is its `name` (e.g. "wave-a1b2c3"), falling back to
213
+ "wave-{wave_number}". Used by `compute_branch_name` for the current wave and
214
+ by the runner to derive a predecessor wave's branch for stacked-wave cuts.
215
+ """
216
+ return name or f"wave-{wave_number}"
217
+
218
+
209
219
  def compute_branch_name(
210
220
  task: dict[str, Any],
211
221
  wave_context: WaveContext | None = None,
@@ -225,7 +235,7 @@ def compute_branch_name(
225
235
  Branch name string.
226
236
  """
227
237
  if wave_context:
228
- return wave_context.wave_name or f"wave-{wave_context.wave_number}"
238
+ return wave_branch_name(wave_context.wave_name, wave_context.wave_number)
229
239
 
230
240
  # Try external ticket ID first (e.g., Linear "PROJ-123")
231
241
  external_id = task.get("linear_identifier") or task.get("external_id")
@@ -0,0 +1,127 @@
1
+ """Tests for the git/commit module (per-task boundary commit)."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from steerdev_agent.git.commit import build_task_commit_message, commit_task_boundary
8
+
9
+ # ── build_task_commit_message ───────────────────────────────────────────────
10
+
11
+
12
+ def test_message_uses_linear_identifier():
13
+ task = {"id": "abc12345xyz", "linear_identifier": "PROJ-123", "title": "Add auth"}
14
+ assert build_task_commit_message(task) == "[PROJ-123] Add auth"
15
+
16
+
17
+ def test_message_falls_back_to_external_id():
18
+ task = {"id": "abc12345xyz", "external_id": "JIRA-9", "title": "Add auth"}
19
+ assert build_task_commit_message(task) == "[JIRA-9] Add auth"
20
+
21
+
22
+ def test_message_prefers_linear_over_external():
23
+ task = {
24
+ "id": "abc12345xyz",
25
+ "linear_identifier": "PROJ-123",
26
+ "external_id": "JIRA-9",
27
+ "title": "Add auth",
28
+ }
29
+ assert build_task_commit_message(task) == "[PROJ-123] Add auth"
30
+
31
+
32
+ def test_message_falls_back_to_short_id():
33
+ task = {"id": "abc12345xyz9999", "title": "Add auth"}
34
+ assert build_task_commit_message(task) == "[task:abc12345] Add auth"
35
+
36
+
37
+ def test_message_handles_missing_title():
38
+ task = {"id": "abc12345xyz", "linear_identifier": "PROJ-123"}
39
+ assert build_task_commit_message(task) == "[PROJ-123]"
40
+
41
+
42
+ # ── commit_task_boundary ─────────────────────────────────────────────────────
43
+
44
+
45
+ def _git(cwd: Path, *args: str) -> None:
46
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
47
+
48
+
49
+ def _make_repo(tmp_path: Path) -> Path:
50
+ work = tmp_path / "work"
51
+ work.mkdir()
52
+ _git(work, "init")
53
+ _git(work, "config", "user.email", "t@t.dev")
54
+ _git(work, "config", "user.name", "t")
55
+ (work / "f.txt").write_text("a")
56
+ _git(work, "add", "-A")
57
+ _git(work, "commit", "-m", "init")
58
+ return work
59
+
60
+
61
+ def _head_message(work: Path) -> str:
62
+ out = subprocess.run(
63
+ ["git", "log", "-1", "--pretty=%s"],
64
+ cwd=work,
65
+ capture_output=True,
66
+ text=True,
67
+ check=True,
68
+ )
69
+ return out.stdout.strip()
70
+
71
+
72
+ def _commit_count(work: Path) -> int:
73
+ out = subprocess.run(
74
+ ["git", "rev-list", "--count", "HEAD"],
75
+ cwd=work,
76
+ capture_output=True,
77
+ text=True,
78
+ check=True,
79
+ )
80
+ return int(out.stdout.strip())
81
+
82
+
83
+ def test_commits_outstanding_changes(tmp_path):
84
+ work = _make_repo(tmp_path)
85
+ _git(work, "checkout", "-b", "wave-1")
86
+ (work / "f.txt").write_text("changed")
87
+ (work / "new.txt").write_text("new file")
88
+
89
+ task = {"id": "abc12345xyz", "linear_identifier": "PROJ-7", "title": "do work"}
90
+ result = asyncio.run(commit_task_boundary(work, task))
91
+
92
+ assert result == "committed"
93
+ assert _head_message(work) == "[PROJ-7] do work"
94
+ # Both the modified and the untracked file are committed (git add -A).
95
+ assert _commit_count(work) == 2
96
+
97
+
98
+ def test_nothing_to_commit_when_clean(tmp_path):
99
+ work = _make_repo(tmp_path)
100
+ _git(work, "checkout", "-b", "wave-1")
101
+
102
+ task = {"id": "abc12345xyz", "title": "nothing"}
103
+ result = asyncio.run(commit_task_boundary(work, task))
104
+
105
+ assert result == "nothing_to_commit"
106
+ assert _commit_count(work) == 1
107
+
108
+
109
+ def test_skips_protected_branch(tmp_path):
110
+ work = _make_repo(tmp_path) # stays on default branch (main/master)
111
+ (work / "f.txt").write_text("changed on protected")
112
+
113
+ task = {"id": "abc12345xyz", "title": "do work"}
114
+ result = asyncio.run(commit_task_boundary(work, task))
115
+
116
+ assert result == "skipped_protected"
117
+ assert _commit_count(work) == 1 # nothing committed
118
+
119
+
120
+ def test_never_raises_on_non_repo(tmp_path):
121
+ not_a_repo = tmp_path / "plain"
122
+ not_a_repo.mkdir()
123
+
124
+ task = {"id": "abc12345xyz", "title": "do work"}
125
+ # Must not raise; returns a status marker.
126
+ result = asyncio.run(commit_task_boundary(not_a_repo, task))
127
+ assert result == "failed"