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.
- {steerdev-1.1.16 → steerdev-1.1.26}/.github/workflows/publish.yml +3 -1
- {steerdev-1.1.16 → steerdev-1.1.26}/PKG-INFO +1 -1
- {steerdev-1.1.16 → steerdev-1.1.26}/pyproject.toml +1 -1
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/hooks.py +27 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/tasks.py +8 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/cli.py +48 -0
- steerdev-1.1.26/src/steerdev_agent/git_ops.py +124 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/claude_setup.py +46 -4
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/settings.json +12 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/preparation.py +40 -4
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/project_manager.py +17 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_claude_setup.py +70 -0
- steerdev-1.1.26/tests/test_git_ops.py +133 -0
- steerdev-1.1.26/tests/test_hooks_post_tool_use.py +78 -0
- steerdev-1.1.26/tests/test_preparation_provenance.py +42 -0
- steerdev-1.1.26/tests/test_tasks_update_branch.py +32 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/.github/workflows/pre-commit.yml +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/.gitignore +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/.pre-commit-config.yaml +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/AGENTS.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/CLAUDE.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/README.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/scripts/pre-commit-version-bump.sh +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/agent_loop.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/activity.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/agents.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/canals.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/client.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/commands.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/configs.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/context.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/events.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/implementation_plan.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/merger.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/messages.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/prd.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/reports.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/runs.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/sessions.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/specs.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/workflow_runs.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/api/workflows.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/models.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/platform.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/config/settings.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/evidence.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/evidence_assets.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/base.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/claude.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/executor/stream.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/handlers/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/handlers/prd.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/integration.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/builder.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/templates.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/prompt/workflow_template.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/py.typed +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/retry.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/runner.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/repo_setup.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/update_check.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/version.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/context.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/executor.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workflow/memory.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/workspace/tool_detection.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/src/steerdev_agent/worktree.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/__init__.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agent_loop.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agent_loop_extended.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_agents_api.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_api_client.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_claude_executor.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_client_methods.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_commands_api.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_config.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_config_extended.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_conflict_mitigation.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_context_search.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_executor.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_platform_config.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_preparation.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_prompt.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_reports_client.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_resolve_max_tasks.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_retry.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_runner_merge_modes.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_runner_worktrees.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_stream_parser.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_tasks.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_version.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_wave_scoped_fetch.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_context.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_memory.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_prompt_template.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workflow_runs_api.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workspace.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_workspace_extended.py +0 -0
- {steerdev-1.1.16 → steerdev-1.1.26}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steerdev
|
|
3
|
-
Version: 1.1.
|
|
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
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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}")
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|