steerdev 1.1.16__tar.gz → 1.1.26__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.16 → steerdev-1.1.26}/.github/workflows/publish.yml +3 -1
  2. {steerdev-1.1.16 → steerdev-1.1.26}/PKG-INFO +1 -1
  3. {steerdev-1.1.16 → steerdev-1.1.26}/pyproject.toml +1 -1
  4. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/hooks.py +27 -0
  5. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/tasks.py +8 -0
  6. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/cli.py +48 -0
  7. steerdev-1.1.26/src/steerdev_agent/git_ops.py +124 -0
  8. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/claude_setup.py +46 -4
  9. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/settings.json +12 -0
  10. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/preparation.py +40 -4
  11. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/project_manager.py +17 -0
  12. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_claude_setup.py +70 -0
  13. steerdev-1.1.26/tests/test_git_ops.py +133 -0
  14. steerdev-1.1.26/tests/test_hooks_post_tool_use.py +78 -0
  15. steerdev-1.1.26/tests/test_preparation_provenance.py +42 -0
  16. steerdev-1.1.26/tests/test_tasks_update_branch.py +32 -0
  17. {steerdev-1.1.16 → steerdev-1.1.26}/.github/workflows/pre-commit.yml +0 -0
  18. {steerdev-1.1.16 → steerdev-1.1.26}/.gitignore +0 -0
  19. {steerdev-1.1.16 → steerdev-1.1.26}/.pre-commit-config.yaml +0 -0
  20. {steerdev-1.1.16 → steerdev-1.1.26}/AGENTS.md +0 -0
  21. {steerdev-1.1.16 → steerdev-1.1.26}/CLAUDE.md +0 -0
  22. {steerdev-1.1.16 → steerdev-1.1.26}/README.md +0 -0
  23. {steerdev-1.1.16 → steerdev-1.1.26}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  24. {steerdev-1.1.16 → steerdev-1.1.26}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  25. {steerdev-1.1.16 → steerdev-1.1.26}/scripts/pre-commit-version-bump.sh +0 -0
  26. {steerdev-1.1.16 → steerdev-1.1.26}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  27. {steerdev-1.1.16 → steerdev-1.1.26}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  28. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/__init__.py +0 -0
  29. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/agent_loop.py +0 -0
  30. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/__init__.py +0 -0
  31. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/activity.py +0 -0
  32. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/agents.py +0 -0
  33. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/canals.py +0 -0
  34. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/client.py +0 -0
  35. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/commands.py +0 -0
  36. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/configs.py +0 -0
  37. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/context.py +0 -0
  38. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/events.py +0 -0
  39. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/implementation_plan.py +0 -0
  40. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/merger.py +0 -0
  41. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/messages.py +0 -0
  42. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/prd.py +0 -0
  43. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/reports.py +0 -0
  44. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/runs.py +0 -0
  45. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/sessions.py +0 -0
  46. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/specs.py +0 -0
  47. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/workflow_runs.py +0 -0
  48. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/workflows.py +0 -0
  49. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/__init__.py +0 -0
  50. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/models.py +0 -0
  51. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/platform.py +0 -0
  52. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/settings.py +0 -0
  53. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/evidence.py +0 -0
  54. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/evidence_assets.py +0 -0
  55. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/__init__.py +0 -0
  56. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/base.py +0 -0
  57. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/claude.py +0 -0
  58. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/stream.py +0 -0
  59. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/handlers/__init__.py +0 -0
  60. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/handlers/prd.py +0 -0
  61. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/integration.py +0 -0
  62. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/__init__.py +0 -0
  63. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/builder.py +0 -0
  64. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/templates.py +0 -0
  65. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  66. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/py.typed +0 -0
  67. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/retry.py +0 -0
  68. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/runner.py +0 -0
  69. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/__init__.py +0 -0
  70. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/repo_setup.py +0 -0
  71. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  72. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  73. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  74. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  75. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  76. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  77. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  78. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  79. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  80. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  81. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  82. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  83. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  84. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  85. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/update_check.py +0 -0
  86. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/version.py +0 -0
  87. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/__init__.py +0 -0
  88. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/context.py +0 -0
  89. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/executor.py +0 -0
  90. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/memory.py +0 -0
  91. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/__init__.py +0 -0
  92. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  93. {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/worktree.py +0 -0
  94. {steerdev-1.1.16 → steerdev-1.1.26}/tests/__init__.py +0 -0
  95. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agent_loop.py +0 -0
  96. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agent_loop_extended.py +0 -0
  97. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agents_api.py +0 -0
  98. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_api_client.py +0 -0
  99. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_claude_executor.py +0 -0
  100. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_client_methods.py +0 -0
  101. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_commands_api.py +0 -0
  102. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_config.py +0 -0
  103. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_config_extended.py +0 -0
  104. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_conflict_mitigation.py +0 -0
  105. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_context_search.py +0 -0
  106. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_executor.py +0 -0
  107. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_platform_config.py +0 -0
  108. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_preparation.py +0 -0
  109. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_prompt.py +0 -0
  110. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_reports_client.py +0 -0
  111. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_resolve_max_tasks.py +0 -0
  112. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_retry.py +0 -0
  113. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_runner_merge_modes.py +0 -0
  114. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_runner_worktrees.py +0 -0
  115. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_stream_parser.py +0 -0
  116. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_tasks.py +0 -0
  117. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_version.py +0 -0
  118. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_wave_scoped_fetch.py +0 -0
  119. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_context.py +0 -0
  120. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_memory.py +0 -0
  121. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_prompt_template.py +0 -0
  122. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_runs_api.py +0 -0
  123. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workspace.py +0 -0
  124. {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workspace_extended.py +0 -0
  125. {steerdev-1.1.16 → steerdev-1.1.26}/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.16
3
+ Version: 1.1.26
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.16"
3
+ version = "1.1.26"
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"
@@ -14,6 +14,43 @@ from loguru import logger
14
14
 
15
15
  from steerdev_agent.config.platform import PlatformConfig
16
16
 
17
+ # Commands owned by steerdev are reconciled to the template; anything else in
18
+ # an existing settings file is treated as user-added and left untouched.
19
+ _STEERDEV_HOOK_PREFIX = "steerdev hooks"
20
+
21
+
22
+ def _is_steerdev_hook_entry(entry: dict) -> bool:
23
+ """Return True if a hook matcher-group entry is steerdev-owned.
24
+
25
+ An entry is steerdev-owned when any of its commands invoke
26
+ `steerdev hooks ...`. Used to reconcile only our own hooks while
27
+ preserving user-added hooks on the same event.
28
+ """
29
+ for hook in entry.get("hooks", []):
30
+ if isinstance(hook, dict) and str(hook.get("command", "")).strip().startswith(
31
+ _STEERDEV_HOOK_PREFIX
32
+ ):
33
+ return True
34
+ return False
35
+
36
+
37
+ def _merge_steerdev_hooks(existing_hooks: dict, template_hooks: dict) -> None:
38
+ """Reconcile steerdev-owned hooks into *existing_hooks* in place.
39
+
40
+ For each event in the template, drop any existing steerdev-owned entries
41
+ (so stale/missing definitions are replaced) while keeping user-added
42
+ entries, then append the template's entries. Events not in the template
43
+ are left untouched.
44
+ """
45
+ for event, template_entries in template_hooks.items():
46
+ user_entries = [
47
+ entry
48
+ for entry in existing_hooks.get(event, [])
49
+ if isinstance(entry, dict) and not _is_steerdev_hook_entry(entry)
50
+ ]
51
+ existing_hooks[event] = user_entries + template_entries
52
+
53
+
17
54
  # ---------------------------------------------------------------------------
18
55
  # CLI dependency checking
19
56
  # ---------------------------------------------------------------------------
@@ -319,13 +356,18 @@ class ClaudeSetup:
319
356
  if rule not in existing["permissions"]["allow"]:
320
357
  existing["permissions"]["allow"].append(rule)
321
358
 
322
- # Merge hooks configuration if include_hooks=True
359
+ # Reconcile hooks configuration if include_hooks=True.
360
+ #
361
+ # The template hooks are steerdev-owned (their commands all start
362
+ # with `steerdev hooks`). We reconcile them by command identity so
363
+ # that pre-existing settings files self-heal when new steerdev
364
+ # hooks are added (e.g. the PostToolUse checkpoint-push hook) or
365
+ # when an existing steerdev hook's definition changes. Any
366
+ # user-added hooks on the same event are preserved untouched.
323
367
  if include_hooks and "hooks" in new_settings:
324
368
  if "hooks" not in existing:
325
369
  existing["hooks"] = {}
326
- for hook_event, hook_configs in new_settings["hooks"].items():
327
- if hook_event not in existing["hooks"]:
328
- existing["hooks"][hook_event] = hook_configs
370
+ _merge_steerdev_hooks(existing["hooks"], new_settings["hooks"])
329
371
 
330
372
  settings_path.write_text(json.dumps(existing, indent=2) + "\n")
331
373
  logger.info(f"Updated existing settings: {settings_path}")
@@ -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,
@@ -45,6 +45,10 @@ class ProjectManager:
45
45
  local_path = self._resolve_path(assignment)
46
46
  if local_path and local_path.is_dir():
47
47
  self._path_cache[assignment.project_id] = local_path
48
+ # Existing checkout: reconcile .claude config so settings files
49
+ # that predate newer steerdev hooks (e.g. the PostToolUse
50
+ # checkpoint-push hook) self-heal on every dispatch.
51
+ self._reconcile_settings(local_path)
48
52
  continue
49
53
 
50
54
  # Project not found locally — bootstrap it
@@ -55,6 +59,19 @@ class ProjectManager:
55
59
  except Exception:
56
60
  logger.exception(f"Failed to setup project {assignment.project_id}")
57
61
 
62
+ def _reconcile_settings(self, project_dir: Path) -> None:
63
+ """Re-apply steerdev-owned .claude/settings.json hooks for an existing
64
+ checkout. Idempotent and best-effort: a setup failure must never block
65
+ an otherwise-usable project.
66
+ """
67
+ from steerdev_agent.setup.claude_setup import ClaudeSetup
68
+
69
+ try:
70
+ setup = ClaudeSetup(project_dir, install_target="project")
71
+ setup.setup_settings(force=False, include_hooks=True)
72
+ except Exception:
73
+ logger.exception(f"Failed to reconcile .claude settings for {project_dir}")
74
+
58
75
  async def setup_project(self, assignment: ProjectAssignment) -> Path | None:
59
76
  """Bootstrap a project using existing setup primitives.
60
77
 
@@ -51,6 +51,76 @@ class TestClaudeSetup:
51
51
  ) # skill now references merge skills by name
52
52
 
53
53
 
54
+ class TestSettingsHookReconciliation:
55
+ """Tests for steerdev-owned hook reconciliation in setup_settings."""
56
+
57
+ @staticmethod
58
+ def _commands(settings: dict, event: str) -> list[str]:
59
+ commands = []
60
+ for entry in settings.get("hooks", {}).get(event, []):
61
+ for hook in entry.get("hooks", []):
62
+ commands.append(hook.get("command"))
63
+ return commands
64
+
65
+ def test_fresh_create_includes_post_tool_use(self, tmp_path):
66
+ import json
67
+
68
+ setup = ClaudeSetup(project_dir=tmp_path)
69
+
70
+ path = setup.setup_settings(force=False, include_hooks=True)
71
+ settings = json.loads(path.read_text())
72
+
73
+ assert "steerdev hooks post-tool-use" in self._commands(settings, "PostToolUse")
74
+
75
+ def test_stale_file_self_heals_post_tool_use(self, tmp_path):
76
+ """A settings file predating the PostToolUse hook gains it on re-run."""
77
+ import json
78
+
79
+ stale = {
80
+ "permissions": {"allow": ["Bash(git commit:*)"]},
81
+ "hooks": {
82
+ "SessionStart": [
83
+ {"hooks": [{"type": "command", "command": "steerdev hooks session-start"}]}
84
+ ]
85
+ },
86
+ }
87
+ claude_dir = tmp_path / ".claude"
88
+ claude_dir.mkdir(parents=True)
89
+ (claude_dir / "settings.json").write_text(json.dumps(stale))
90
+
91
+ setup = ClaudeSetup(project_dir=tmp_path)
92
+ path = setup.setup_settings(force=False, include_hooks=True)
93
+ settings = json.loads(path.read_text())
94
+
95
+ assert "steerdev hooks post-tool-use" in self._commands(settings, "PostToolUse")
96
+ # Pre-existing steerdev hook is reconciled, not duplicated.
97
+ assert self._commands(settings, "SessionStart").count("steerdev hooks session-start") == 1
98
+
99
+ def test_user_added_hooks_preserved(self, tmp_path):
100
+ import json
101
+
102
+ existing = {
103
+ "hooks": {
104
+ "PostToolUse": [
105
+ {
106
+ "matcher": "Write",
107
+ "hooks": [{"type": "command", "command": "my-custom-linter"}],
108
+ }
109
+ ]
110
+ },
111
+ }
112
+ claude_dir = tmp_path / ".claude"
113
+ claude_dir.mkdir(parents=True)
114
+ (claude_dir / "settings.json").write_text(json.dumps(existing))
115
+
116
+ setup = ClaudeSetup(project_dir=tmp_path)
117
+ path = setup.setup_settings(force=False, include_hooks=True)
118
+ commands = self._commands(json.loads(path.read_text()), "PostToolUse")
119
+
120
+ assert "my-custom-linter" in commands
121
+ assert "steerdev hooks post-tool-use" in commands
122
+
123
+
54
124
  class TestCheckCliDependencies:
55
125
  """Tests for CLI dependency checking."""
56
126