steerdev 1.1.14__tar.gz → 1.1.25__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 (125) hide show
  1. {steerdev-1.1.14 → steerdev-1.1.25}/.github/workflows/publish.yml +3 -1
  2. {steerdev-1.1.14 → steerdev-1.1.25}/PKG-INFO +1 -1
  3. {steerdev-1.1.14 → steerdev-1.1.25}/pyproject.toml +1 -1
  4. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/hooks.py +27 -0
  5. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/tasks.py +8 -0
  6. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/cli.py +48 -0
  7. steerdev-1.1.25/src/steerdev_agent/git_ops.py +124 -0
  8. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/settings.json +12 -0
  9. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workspace/preparation.py +40 -4
  10. steerdev-1.1.25/tests/test_git_ops.py +133 -0
  11. steerdev-1.1.25/tests/test_hooks_post_tool_use.py +78 -0
  12. steerdev-1.1.25/tests/test_preparation_provenance.py +42 -0
  13. steerdev-1.1.25/tests/test_tasks_update_branch.py +32 -0
  14. {steerdev-1.1.14 → steerdev-1.1.25}/.github/workflows/pre-commit.yml +0 -0
  15. {steerdev-1.1.14 → steerdev-1.1.25}/.gitignore +0 -0
  16. {steerdev-1.1.14 → steerdev-1.1.25}/.pre-commit-config.yaml +0 -0
  17. {steerdev-1.1.14 → steerdev-1.1.25}/AGENTS.md +0 -0
  18. {steerdev-1.1.14 → steerdev-1.1.25}/CLAUDE.md +0 -0
  19. {steerdev-1.1.14 → steerdev-1.1.25}/README.md +0 -0
  20. {steerdev-1.1.14 → steerdev-1.1.25}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  21. {steerdev-1.1.14 → steerdev-1.1.25}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  22. {steerdev-1.1.14 → steerdev-1.1.25}/scripts/pre-commit-version-bump.sh +0 -0
  23. {steerdev-1.1.14 → steerdev-1.1.25}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  24. {steerdev-1.1.14 → steerdev-1.1.25}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  25. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/__init__.py +0 -0
  26. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/agent_loop.py +0 -0
  27. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/__init__.py +0 -0
  28. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/activity.py +0 -0
  29. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/agents.py +0 -0
  30. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/canals.py +0 -0
  31. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/client.py +0 -0
  32. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/commands.py +0 -0
  33. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/configs.py +0 -0
  34. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/context.py +0 -0
  35. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/events.py +0 -0
  36. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/implementation_plan.py +0 -0
  37. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/merger.py +0 -0
  38. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/messages.py +0 -0
  39. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/prd.py +0 -0
  40. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/reports.py +0 -0
  41. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/runs.py +0 -0
  42. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/sessions.py +0 -0
  43. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/specs.py +0 -0
  44. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/workflow_runs.py +0 -0
  45. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/api/workflows.py +0 -0
  46. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/config/__init__.py +0 -0
  47. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/config/models.py +0 -0
  48. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/config/platform.py +0 -0
  49. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/config/settings.py +0 -0
  50. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/evidence.py +0 -0
  51. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/evidence_assets.py +0 -0
  52. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/executor/__init__.py +0 -0
  53. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/executor/base.py +0 -0
  54. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/executor/claude.py +0 -0
  55. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/executor/stream.py +0 -0
  56. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/handlers/__init__.py +0 -0
  57. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/handlers/prd.py +0 -0
  58. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/integration.py +0 -0
  59. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/prompt/__init__.py +0 -0
  60. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/prompt/builder.py +0 -0
  61. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/prompt/templates.py +0 -0
  62. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  63. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/py.typed +0 -0
  64. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/retry.py +0 -0
  65. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/runner.py +0 -0
  66. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/__init__.py +0 -0
  67. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/claude_setup.py +0 -0
  68. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/repo_setup.py +0 -0
  69. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  70. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  71. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  72. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  73. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  74. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  75. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  76. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  77. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  78. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  79. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  80. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  81. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  82. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  83. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/update_check.py +0 -0
  84. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/version.py +0 -0
  85. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workflow/__init__.py +0 -0
  86. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workflow/context.py +0 -0
  87. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workflow/executor.py +0 -0
  88. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workflow/memory.py +0 -0
  89. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workspace/__init__.py +0 -0
  90. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workspace/project_manager.py +0 -0
  91. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  92. {steerdev-1.1.14 → steerdev-1.1.25}/src/steerdev_agent/worktree.py +0 -0
  93. {steerdev-1.1.14 → steerdev-1.1.25}/tests/__init__.py +0 -0
  94. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_agent_loop.py +0 -0
  95. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_agent_loop_extended.py +0 -0
  96. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_agents_api.py +0 -0
  97. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_api_client.py +0 -0
  98. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_claude_executor.py +0 -0
  99. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_claude_setup.py +0 -0
  100. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_client_methods.py +0 -0
  101. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_commands_api.py +0 -0
  102. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_config.py +0 -0
  103. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_config_extended.py +0 -0
  104. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_conflict_mitigation.py +0 -0
  105. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_context_search.py +0 -0
  106. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_executor.py +0 -0
  107. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_platform_config.py +0 -0
  108. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_preparation.py +0 -0
  109. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_prompt.py +0 -0
  110. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_reports_client.py +0 -0
  111. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_resolve_max_tasks.py +0 -0
  112. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_retry.py +0 -0
  113. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_runner_merge_modes.py +0 -0
  114. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_runner_worktrees.py +0 -0
  115. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_stream_parser.py +0 -0
  116. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_tasks.py +0 -0
  117. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_version.py +0 -0
  118. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_wave_scoped_fetch.py +0 -0
  119. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workflow_context.py +0 -0
  120. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workflow_memory.py +0 -0
  121. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workflow_prompt_template.py +0 -0
  122. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workflow_runs_api.py +0 -0
  123. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workspace.py +0 -0
  124. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_workspace_extended.py +0 -0
  125. {steerdev-1.1.14 → steerdev-1.1.25}/tests/test_worktree.py +0 -0
@@ -56,7 +56,9 @@ jobs:
56
56
  run: uv build
57
57
 
58
58
  - name: Publish to PyPI
59
- run: uv publish --trusted-publishing always
59
+ env:
60
+ UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
61
+ run: uv publish
60
62
 
61
63
  - name: Create git tag and GitHub release
62
64
  env:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.14
3
+ Version: 1.1.25
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.14"
3
+ version = "1.1.25"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,6 +8,7 @@ from typing import Any
8
8
  import httpx
9
9
 
10
10
  from steerdev_agent.api.client import get_agent_name, get_api_endpoint, get_api_key
11
+ from steerdev_agent.git_ops import checkpoint_push, is_git_commit_command
11
12
 
12
13
 
13
14
  class HooksClient:
@@ -167,3 +168,29 @@ class HooksClient:
167
168
  },
168
169
  )
169
170
  sys.exit(0)
171
+
172
+ def post_tool_use(self) -> None:
173
+ """Process PostToolUse hook event for checkpoint pushes.
174
+
175
+ If the tool was a Bash `git commit`, push the current branch to
176
+ origin as a checkpoint so sandbox work survives a VM teardown.
177
+ Best-effort: always exits 0, never blocks the agent. Does not
178
+ report to the activity API.
179
+ """
180
+ input_data = self.read_stdin_input()
181
+ if not isinstance(input_data, dict):
182
+ sys.exit(0)
183
+
184
+ if input_data.get("tool_name") != "Bash":
185
+ sys.exit(0)
186
+
187
+ tool_input = input_data.get("tool_input")
188
+ if not isinstance(tool_input, dict):
189
+ sys.exit(0)
190
+ command = tool_input.get("command")
191
+ if not is_git_commit_command(command):
192
+ sys.exit(0)
193
+
194
+ cwd = input_data.get("cwd") or "."
195
+ checkpoint_push(cwd)
196
+ sys.exit(0)
@@ -223,6 +223,8 @@ class TasksClient(SteerDevClient):
223
223
  result_summary: str | None = None,
224
224
  error_message: str | None = None,
225
225
  comment: str | None = None,
226
+ branch: str | None = None,
227
+ base_branch: str | None = None,
226
228
  ) -> bool:
227
229
  """Update a task's fields.
228
230
 
@@ -235,6 +237,8 @@ class TasksClient(SteerDevClient):
235
237
  result_summary: Result summary for completed tasks.
236
238
  error_message: Error message for failed tasks.
237
239
  comment: Comment to add.
240
+ branch: Task branch name (provenance).
241
+ base_branch: Branch the task branch was created from (provenance).
238
242
 
239
243
  Returns:
240
244
  True if update succeeded.
@@ -266,6 +270,10 @@ class TasksClient(SteerDevClient):
266
270
  payload["error_message"] = error_message
267
271
  if comment:
268
272
  payload["comment"] = comment
273
+ if branch:
274
+ payload["branch"] = branch
275
+ if base_branch:
276
+ payload["base_branch"] = base_branch
269
277
 
270
278
  if not payload:
271
279
  return False
@@ -460,6 +460,20 @@ def hooks_subagent_stop() -> None:
460
460
  client.subagent_stop()
461
461
 
462
462
 
463
+ @hooks_app.command("post-tool-use")
464
+ def hooks_post_tool_use() -> None:
465
+ """Hook called after each Claude Code tool use.
466
+
467
+ Reads JSON from stdin. If the tool was a Bash `git commit`, pushes the
468
+ current branch to origin as a checkpoint so sandbox work survives a VM
469
+ teardown. Best-effort; always exits 0.
470
+ """
471
+ from steerdev_agent.api.hooks import HooksClient
472
+
473
+ client = HooksClient()
474
+ client.post_tool_use()
475
+
476
+
463
477
  # ============================================================================
464
478
  # Sessions Command Group
465
479
  # ============================================================================
@@ -1272,6 +1286,16 @@ def git_branch(
1272
1286
  branch_name = f"task/{prefix}-{slug}" if slug else f"task/{prefix}"
1273
1287
 
1274
1288
  try:
1289
+ # Capture the base branch (current HEAD) before we switch.
1290
+ base_result = subprocess.run(
1291
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1292
+ capture_output=True,
1293
+ text=True,
1294
+ )
1295
+ base_branch = base_result.stdout.strip() if base_result.returncode == 0 else None
1296
+ if base_branch == "HEAD":
1297
+ base_branch = None
1298
+
1275
1299
  # Check if branch already exists
1276
1300
  result = subprocess.run(
1277
1301
  ["git", "rev-parse", "--verify", branch_name],
@@ -1297,6 +1321,14 @@ def git_branch(
1297
1321
  )
1298
1322
  )
1299
1323
 
1324
+ # Persist branch provenance (best-effort; never fails the command).
1325
+ try:
1326
+ with TasksClient() as prov_client:
1327
+ if prov_client.check_api_key():
1328
+ prov_client.update_task(task_id, branch=branch_name, base_branch=base_branch)
1329
+ except Exception as prov_exc: # provenance is best-effort
1330
+ console.print(f"[yellow]Could not persist branch provenance: {prov_exc}[/yellow]")
1331
+
1300
1332
  except subprocess.CalledProcessError as e:
1301
1333
  console.print(f"[red]Git error: {e}[/red]")
1302
1334
  raise typer.Exit(1) from None
@@ -1344,9 +1376,25 @@ def git_pr(
1344
1376
  # Add steerdev signature
1345
1377
  pr_body += "\n\n---\nCreated with [steerdev](https://steerdev.com)"
1346
1378
 
1379
+ # 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
+
1347
1393
  try:
1348
1394
  # Build the gh pr create command
1349
1395
  cmd = ["gh", "pr", "create", "--title", title, "--body", pr_body]
1396
+ if base_branch:
1397
+ cmd += ["--base", base_branch]
1350
1398
  if draft:
1351
1399
  cmd.append("--draft")
1352
1400
 
@@ -0,0 +1,124 @@
1
+ """Synchronous git helpers for checkpoint pushes from Claude Code hooks.
2
+
3
+ Used by the PostToolUse hook to push the current branch after every commit,
4
+ so sandbox work survives a mid-task VM teardown. All functions are
5
+ best-effort and never raise.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from typing import TYPE_CHECKING
14
+
15
+ from loguru import logger
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ # Matches a `git commit` invocation that actually creates a commit.
21
+ # Requires `git` to be followed by whitespace (not a hyphen), so that
22
+ # hyphenated wrappers like `git-commit-hook` are not matched.
23
+ _COMMIT_RE = re.compile(r"(?:^|&&|;|\|)\s*git\s+[^&|;]*\bcommit\b")
24
+
25
+ # Strips quoted substrings ('...' and "...") so that --dry-run inside a
26
+ # commit message is not mistaken for the actual --dry-run flag.
27
+ _QUOTED_RE = re.compile(r"""(['"]).*?\1""")
28
+
29
+
30
+ def _strip_quoted(command: str) -> str:
31
+ """Remove all single- and double-quoted substrings from *command*."""
32
+ return _QUOTED_RE.sub("", command)
33
+
34
+
35
+ def is_git_commit_command(command: str | None) -> bool:
36
+ """Return True if the shell command runs a real `git commit`.
37
+
38
+ Tolerant of flags, paths, and compound commands (&&, ;). Excludes
39
+ `--dry-run`, which does not create a commit.
40
+
41
+ A `--dry-run` flag (outside quoted text) is treated as a non-committing
42
+ command. If `--dry-run` appears only inside a quoted commit message it is
43
+ ignored and the command is still considered committing.
44
+
45
+ Args:
46
+ command: The Bash command string from a PostToolUse payload.
47
+
48
+ Returns:
49
+ True if a committing `git commit` is present.
50
+ """
51
+ if not command:
52
+ return False
53
+ if "--dry-run" in _strip_quoted(command):
54
+ return False
55
+ return _COMMIT_RE.search(command) is not None
56
+
57
+
58
+ PROTECTED_BRANCHES: frozenset[str] = frozenset({"main", "master", "develop"})
59
+
60
+ _GIT_TIMEOUT = 25 # seconds; hook timeout is 30s, leave headroom
61
+
62
+
63
+ def _git_capture(cwd: str | Path, *args: str) -> tuple[int, str]:
64
+ """Run git, returning (returncode, stdout). Never raises for git errors."""
65
+ proc = subprocess.run(
66
+ ["git", *args],
67
+ cwd=str(cwd),
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=_GIT_TIMEOUT,
71
+ )
72
+ return proc.returncode, (proc.stdout or "").strip()
73
+
74
+
75
+ def current_branch(cwd: str | Path) -> str | None:
76
+ """Return the current branch name, or None if detached/unavailable."""
77
+ rc, out = _git_capture(cwd, "rev-parse", "--abbrev-ref", "HEAD")
78
+ if rc != 0 or not out or out == "HEAD":
79
+ return None
80
+ return out
81
+
82
+
83
+ def checkpoint_push(cwd: str | Path) -> str:
84
+ """Push the current branch to origin as a best-effort checkpoint.
85
+
86
+ Force-with-lease so rebases/amends still checkpoint without ever
87
+ overwriting others' work. Never raises; returns a status marker.
88
+
89
+ Returns one of:
90
+ "pushed", "skipped_detached", "skipped_protected",
91
+ "skipped_no_remote", "failed".
92
+ """
93
+ try:
94
+ if shutil.which("git") is None:
95
+ logger.warning("checkpoint_push: git not found, skipping")
96
+ return "failed"
97
+
98
+ branch = current_branch(cwd)
99
+ if branch is None:
100
+ logger.info("checkpoint_push: detached HEAD, skipping")
101
+ return "skipped_detached"
102
+
103
+ if branch in PROTECTED_BRANCHES:
104
+ logger.info(f"checkpoint_push: protected branch {branch}, skipping")
105
+ return "skipped_protected"
106
+
107
+ rc, _ = _git_capture(cwd, "remote", "get-url", "origin")
108
+ if rc != 0:
109
+ logger.info("checkpoint_push: no origin remote, skipping")
110
+ return "skipped_no_remote"
111
+
112
+ rc, out = _git_capture(cwd, "push", "--force-with-lease", "-u", "origin", "HEAD")
113
+ if rc != 0:
114
+ logger.warning(f"checkpoint_push: push failed (non-fatal): {out}")
115
+ return "failed"
116
+
117
+ logger.info(f"checkpoint_push: pushed {branch}")
118
+ return "pushed"
119
+ except subprocess.TimeoutExpired:
120
+ logger.warning("checkpoint_push: push timed out (non-fatal)")
121
+ return "failed"
122
+ except Exception as exc: # broad catch intentional: durability must never break the agent
123
+ logger.warning(f"checkpoint_push: unexpected error (non-fatal): {exc}")
124
+ return "failed"
@@ -69,6 +69,18 @@
69
69
  }
70
70
  ]
71
71
  }
72
+ ],
73
+ "PostToolUse": [
74
+ {
75
+ "matcher": "Bash",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "steerdev hooks post-tool-use",
80
+ "timeout": 30
81
+ }
82
+ ]
83
+ }
72
84
  ]
73
85
  }
74
86
  }
@@ -13,6 +13,8 @@ from typing import TYPE_CHECKING, Any
13
13
 
14
14
  from loguru import logger
15
15
 
16
+ from steerdev_agent.api.tasks import TasksClient
17
+
16
18
  if TYPE_CHECKING:
17
19
  from pathlib import Path
18
20
 
@@ -78,6 +80,27 @@ async def _run_git(
78
80
  return proc.returncode or 0, stdout, stderr
79
81
 
80
82
 
83
+ async def persist_branch_provenance(
84
+ task_id: str | None,
85
+ branch: str,
86
+ base_branch: str,
87
+ ) -> None:
88
+ """Persist the task branch and its base to the API, best-effort.
89
+
90
+ Written at branch-creation time so an orphaned task (sandbox killed
91
+ mid-run) remains recoverable. Never raises.
92
+ """
93
+ if not task_id:
94
+ return
95
+ try:
96
+ with TasksClient() as client:
97
+ if not client.check_api_key():
98
+ return
99
+ client.update_task(task_id, branch=branch, base_branch=base_branch)
100
+ except Exception as exc: # provenance is best-effort
101
+ logger.warning(f"Failed to persist branch provenance for {task_id}: {exc}")
102
+
103
+
81
104
  class WorkspacePreparation:
82
105
  """Prepares the git workspace before task execution.
83
106
 
@@ -120,7 +143,9 @@ class WorkspacePreparation:
120
143
  return await self._prepare_worktree(target_branch, default_branch)
121
144
 
122
145
  # Non-worktree mode: prepare in-place
123
- return await self._prepare_in_place(target_branch, default_branch, wave_context)
146
+ return await self._prepare_in_place(
147
+ target_branch, default_branch, wave_context, task.get("id")
148
+ )
124
149
 
125
150
  async def _prepare_worktree(
126
151
  self,
@@ -146,6 +171,7 @@ class WorkspacePreparation:
146
171
  target_branch: str,
147
172
  default_branch: str,
148
173
  wave_context: WaveContext | None,
174
+ task_id: str | None = None,
149
175
  ) -> PreparationResult:
150
176
  """Prepare workspace in the main repository directory.
151
177
 
@@ -159,7 +185,9 @@ class WorkspacePreparation:
159
185
  rc, _, _ = await _run_git("rev-parse", "--is-inside-work-tree", cwd=cwd, check=False)
160
186
  if rc == 0:
161
187
  # Single-repo layout: prepare in place
162
- return await self._prepare_single_repo(cwd, target_branch, default_branch, wave_context)
188
+ return await self._prepare_single_repo(
189
+ cwd, target_branch, default_branch, wave_context, task_id
190
+ )
163
191
 
164
192
  # Not a git repo — check for multi-repo layout (subdirectories that are git repos)
165
193
  repos = await self._discover_git_repos(cwd)
@@ -174,7 +202,9 @@ class WorkspacePreparation:
174
202
  )
175
203
 
176
204
  # Multi-repo layout: prepare each discovered repo
177
- return await self._prepare_multi_repo(repos, target_branch, default_branch, wave_context)
205
+ return await self._prepare_multi_repo(
206
+ repos, target_branch, default_branch, wave_context, task_id
207
+ )
178
208
 
179
209
  async def _discover_git_repos(self, directory: Path) -> list[Path]:
180
210
  """Discover git repositories in immediate subdirectories.
@@ -204,6 +234,7 @@ class WorkspacePreparation:
204
234
  target_branch: str,
205
235
  default_branch: str,
206
236
  wave_context: WaveContext | None,
237
+ task_id: str | None = None,
207
238
  ) -> PreparationResult:
208
239
  """Prepare multiple git repositories in a workspace directory.
209
240
 
@@ -232,7 +263,7 @@ class WorkspacePreparation:
232
263
  logger.info(f"Preparing repository: {repo_name}")
233
264
 
234
265
  result = await self._prepare_single_repo(
235
- repo_path, target_branch, default_branch, wave_context
266
+ repo_path, target_branch, default_branch, wave_context, task_id
236
267
  )
237
268
 
238
269
  if not result.success:
@@ -269,6 +300,7 @@ class WorkspacePreparation:
269
300
  target_branch: str,
270
301
  default_branch: str,
271
302
  wave_context: WaveContext | None,
303
+ task_id: str | None = None,
272
304
  ) -> PreparationResult:
273
305
  """Prepare a single git repository directory.
274
306
 
@@ -355,6 +387,10 @@ class WorkspacePreparation:
355
387
  error=f"Branch mismatch: expected {target_branch}, got {actual_branch}",
356
388
  )
357
389
 
390
+ await persist_branch_provenance(
391
+ task_id=task_id, branch=target_branch, base_branch=default_branch
392
+ )
393
+
358
394
  return PreparationResult(
359
395
  success=True,
360
396
  branch_name=target_branch,
@@ -0,0 +1,133 @@
1
+ """Tests for the git_ops module (commit detection and checkpoint push)."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from steerdev_agent.git_ops import PROTECTED_BRANCHES, checkpoint_push, is_git_commit_command
7
+
8
+
9
+ def test_detects_plain_commit():
10
+ assert is_git_commit_command("git commit -m 'msg'") is True
11
+
12
+
13
+ def test_detects_commit_with_amend():
14
+ assert is_git_commit_command("git commit --amend --no-edit") is True
15
+
16
+
17
+ def test_detects_commit_in_compound_command():
18
+ assert is_git_commit_command("git add -A && git commit -m 'x'") is True
19
+
20
+
21
+ def test_ignores_dry_run():
22
+ assert is_git_commit_command("git commit --dry-run") is False
23
+
24
+
25
+ def test_ignores_non_commit_git():
26
+ assert is_git_commit_command("git status") is False
27
+ assert is_git_commit_command("git log --oneline") is False
28
+
29
+
30
+ def test_ignores_unrelated_command():
31
+ assert is_git_commit_command("echo git commit") is False
32
+ assert is_git_commit_command("ls -la") is False
33
+
34
+
35
+ def test_handles_empty():
36
+ assert is_git_commit_command("") is False
37
+ assert is_git_commit_command(None) is False
38
+
39
+
40
+ def test_ignores_hyphenated_git_wrapper():
41
+ assert is_git_commit_command("git-commit-hook") is False
42
+ assert is_git_commit_command("build && git-commit-hook") is False
43
+
44
+
45
+ def test_detects_commit_with_extra_spaces():
46
+ assert is_git_commit_command("git commit -m x") is True
47
+
48
+
49
+ def test_dry_run_inside_message_still_commits():
50
+ assert is_git_commit_command('git commit -m "talk about --dry-run"') is True
51
+ assert is_git_commit_command("git commit -m 'fix --dry-run bug'") is True
52
+
53
+
54
+ def _git(cwd: Path, *args: str) -> None:
55
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
56
+
57
+
58
+ def _make_repo_with_origin(tmp_path: Path) -> Path:
59
+ origin = tmp_path / "origin.git"
60
+ subprocess.run(["git", "init", "--bare", str(origin)], check=True, capture_output=True)
61
+ work = tmp_path / "work"
62
+ work.mkdir()
63
+ _git(work, "init")
64
+ _git(work, "config", "user.email", "t@t.dev")
65
+ _git(work, "config", "user.name", "t")
66
+ _git(work, "remote", "add", "origin", str(origin))
67
+ (work / "f.txt").write_text("a")
68
+ _git(work, "add", "-A")
69
+ _git(work, "commit", "-m", "init")
70
+ return work
71
+
72
+
73
+ def _remote_has_branch(work: Path, branch: str) -> bool:
74
+ out = subprocess.run(
75
+ ["git", "ls-remote", "--heads", "origin", branch],
76
+ cwd=work,
77
+ capture_output=True,
78
+ text=True,
79
+ check=True,
80
+ )
81
+ return bool(out.stdout.strip())
82
+
83
+
84
+ def test_pushes_task_branch(tmp_path):
85
+ work = _make_repo_with_origin(tmp_path)
86
+ _git(work, "checkout", "-b", "task/PROJ-1-x")
87
+ (work / "f.txt").write_text("b")
88
+ _git(work, "add", "-A")
89
+ _git(work, "commit", "-m", "work")
90
+
91
+ assert checkpoint_push(work) == "pushed"
92
+ assert _remote_has_branch(work, "task/PROJ-1-x") is True
93
+
94
+
95
+ def test_skips_protected_branch(tmp_path):
96
+ work = _make_repo_with_origin(tmp_path)
97
+ current = subprocess.run(
98
+ ["git", "branch", "--show-current"], cwd=work, capture_output=True, text=True, check=True
99
+ ).stdout.strip()
100
+ assert current in PROTECTED_BRANCHES # main or master
101
+ assert checkpoint_push(work) == "skipped_protected"
102
+
103
+
104
+ def test_skips_detached_head(tmp_path):
105
+ work = _make_repo_with_origin(tmp_path)
106
+ sha = subprocess.run(
107
+ ["git", "rev-parse", "HEAD"], cwd=work, capture_output=True, text=True, check=True
108
+ ).stdout.strip()
109
+ _git(work, "checkout", sha) # detached
110
+ assert checkpoint_push(work) == "skipped_detached"
111
+
112
+
113
+ def test_skips_when_no_origin(tmp_path):
114
+ work = tmp_path / "noorigin"
115
+ work.mkdir()
116
+ _git(work, "init")
117
+ _git(work, "config", "user.email", "t@t.dev")
118
+ _git(work, "config", "user.name", "t")
119
+ (work / "f.txt").write_text("a")
120
+ _git(work, "add", "-A")
121
+ _git(work, "commit", "-m", "init")
122
+ _git(work, "checkout", "-b", "task/x")
123
+ assert checkpoint_push(work) == "skipped_no_remote"
124
+
125
+
126
+ def test_returns_failed_without_raising_on_unreachable_remote(tmp_path):
127
+ work = _make_repo_with_origin(tmp_path)
128
+ _git(work, "remote", "set-url", "origin", str(tmp_path / "does-not-exist.git"))
129
+ _git(work, "checkout", "-b", "task/x")
130
+ (work / "f.txt").write_text("b")
131
+ _git(work, "add", "-A")
132
+ _git(work, "commit", "-m", "work")
133
+ assert checkpoint_push(work) == "failed"
@@ -0,0 +1,78 @@
1
+ import io
2
+ import json
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+
7
+ from steerdev_agent.api.hooks import HooksClient
8
+
9
+
10
+ def _run_with_stdin(payload: dict, monkeypatch) -> str | None:
11
+ """Drive post_tool_use with a fake stdin; capture the cwd it pushed (or None)."""
12
+ monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload)))
13
+ captured: dict = {}
14
+
15
+ def fake_push(cwd):
16
+ captured["cwd"] = str(cwd)
17
+ return "pushed"
18
+
19
+ with (
20
+ patch("steerdev_agent.api.hooks.checkpoint_push", side_effect=fake_push),
21
+ pytest.raises(SystemExit) as exc,
22
+ ):
23
+ HooksClient().post_tool_use()
24
+ assert exc.value.code == 0
25
+ return captured.get("cwd")
26
+
27
+
28
+ def test_pushes_on_git_commit(monkeypatch):
29
+ payload = {
30
+ "tool_name": "Bash",
31
+ "tool_input": {"command": "git commit -m 'x'"},
32
+ "cwd": "/work/repo",
33
+ }
34
+ assert _run_with_stdin(payload, monkeypatch) == "/work/repo"
35
+
36
+
37
+ def test_no_push_on_non_commit_bash(monkeypatch):
38
+ payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": "/work/repo"}
39
+ assert _run_with_stdin(payload, monkeypatch) is None
40
+
41
+
42
+ def test_no_push_on_non_bash_tool(monkeypatch):
43
+ payload = {"tool_name": "Edit", "tool_input": {"file_path": "a.py"}, "cwd": "/work/repo"}
44
+ assert _run_with_stdin(payload, monkeypatch) is None
45
+
46
+
47
+ def test_exits_zero_on_malformed_stdin(monkeypatch):
48
+ monkeypatch.setattr("sys.stdin", io.StringIO("not json"))
49
+ with (
50
+ patch("steerdev_agent.api.hooks.checkpoint_push") as push,
51
+ pytest.raises(SystemExit) as exc,
52
+ ):
53
+ HooksClient().post_tool_use()
54
+ assert exc.value.code == 0
55
+ push.assert_not_called()
56
+
57
+
58
+ def test_exits_zero_on_null_tool_input(monkeypatch):
59
+ payload = {"tool_name": "Bash", "tool_input": None, "cwd": "/work/repo"}
60
+ assert _run_with_stdin(payload, monkeypatch) is None
61
+
62
+
63
+ def test_exits_zero_on_non_dict_stdin(monkeypatch):
64
+ """A truthy non-dict JSON payload must not raise (never-block invariant)."""
65
+ monkeypatch.setattr("sys.stdin", io.StringIO("42"))
66
+ with (
67
+ patch("steerdev_agent.api.hooks.checkpoint_push") as push,
68
+ pytest.raises(SystemExit) as exc,
69
+ ):
70
+ HooksClient().post_tool_use()
71
+ assert exc.value.code == 0
72
+ push.assert_not_called()
73
+
74
+
75
+ def test_exits_zero_on_string_tool_input(monkeypatch):
76
+ """A truthy non-dict tool_input must not raise."""
77
+ payload = {"tool_name": "Bash", "tool_input": "git commit -m x", "cwd": "/work/repo"}
78
+ assert _run_with_stdin(payload, monkeypatch) is None
@@ -0,0 +1,42 @@
1
+ import asyncio
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from steerdev_agent.workspace.preparation import persist_branch_provenance
5
+
6
+
7
+ def test_persist_calls_update_task_with_branch_and_base():
8
+ fake_client = MagicMock()
9
+ fake_client.check_api_key.return_value = True
10
+ fake_client.__enter__.return_value = fake_client
11
+ fake_client.__exit__.return_value = False
12
+
13
+ with patch("steerdev_agent.workspace.preparation.TasksClient", return_value=fake_client):
14
+ asyncio.run(
15
+ persist_branch_provenance(
16
+ task_id="task-9", branch="task/PROJ-1-x", base_branch="develop"
17
+ )
18
+ )
19
+
20
+ fake_client.update_task.assert_called_once_with(
21
+ "task-9", branch="task/PROJ-1-x", base_branch="develop"
22
+ )
23
+
24
+
25
+ def test_persist_swallows_errors():
26
+ fake_client = MagicMock()
27
+ fake_client.check_api_key.return_value = True
28
+ fake_client.__enter__.return_value = fake_client
29
+ fake_client.__exit__.return_value = False
30
+ fake_client.update_task.side_effect = RuntimeError("network down")
31
+
32
+ with patch("steerdev_agent.workspace.preparation.TasksClient", return_value=fake_client):
33
+ # Must not raise.
34
+ asyncio.run(
35
+ persist_branch_provenance(task_id="task-9", branch="task/x", base_branch="main")
36
+ )
37
+
38
+
39
+ def test_persist_noop_without_task_id():
40
+ with patch("steerdev_agent.workspace.preparation.TasksClient") as cls:
41
+ asyncio.run(persist_branch_provenance(task_id=None, branch="b", base_branch="m"))
42
+ cls.assert_not_called()
@@ -0,0 +1,32 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ from steerdev_agent.api.tasks import TasksClient
4
+
5
+
6
+ def _client() -> TasksClient:
7
+ # Bypass __init__ network/key setup; we only test payload building.
8
+ client = TasksClient.__new__(TasksClient)
9
+ client.api_base = "https://api.test/v1"
10
+ return client
11
+
12
+
13
+ def test_update_task_sends_branch_and_base():
14
+ client = _client()
15
+ resp = MagicMock(status_code=200)
16
+ with patch.object(client, "patch", return_value=resp) as mock_patch:
17
+ ok = client.update_task("task-123", branch="task/PROJ-1-x", base_branch="develop")
18
+ assert ok is True
19
+ args, kwargs = mock_patch.call_args
20
+ assert args[0] == "/tasks/task-123"
21
+ assert kwargs["json"]["branch"] == "task/PROJ-1-x"
22
+ assert kwargs["json"]["base_branch"] == "develop"
23
+
24
+
25
+ def test_update_task_omits_branch_when_not_given():
26
+ client = _client()
27
+ resp = MagicMock(status_code=200)
28
+ with patch.object(client, "patch", return_value=resp) as mock_patch:
29
+ client.update_task("task-123", result_summary="done")
30
+ _, kwargs = mock_patch.call_args
31
+ assert "branch" not in kwargs["json"]
32
+ assert "base_branch" not in kwargs["json"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes