steerdev 1.1.25__tar.gz → 1.1.27__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.25 → steerdev-1.1.27}/PKG-INFO +1 -1
  2. {steerdev-1.1.25 → steerdev-1.1.27}/pyproject.toml +1 -1
  3. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/agent_loop.py +24 -2
  4. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/claude_setup.py +46 -4
  5. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workspace/preparation.py +7 -0
  6. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workspace/project_manager.py +17 -0
  7. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_agent_loop.py +31 -0
  8. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_claude_setup.py +70 -0
  9. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_preparation.py +9 -1
  10. {steerdev-1.1.25 → steerdev-1.1.27}/.github/workflows/pre-commit.yml +0 -0
  11. {steerdev-1.1.25 → steerdev-1.1.27}/.github/workflows/publish.yml +0 -0
  12. {steerdev-1.1.25 → steerdev-1.1.27}/.gitignore +0 -0
  13. {steerdev-1.1.25 → steerdev-1.1.27}/.pre-commit-config.yaml +0 -0
  14. {steerdev-1.1.25 → steerdev-1.1.27}/AGENTS.md +0 -0
  15. {steerdev-1.1.25 → steerdev-1.1.27}/CLAUDE.md +0 -0
  16. {steerdev-1.1.25 → steerdev-1.1.27}/README.md +0 -0
  17. {steerdev-1.1.25 → steerdev-1.1.27}/docs/superpowers/plans/2026-06-05-wave-id-implies-unlimited-max-tasks.md +0 -0
  18. {steerdev-1.1.25 → steerdev-1.1.27}/docs/superpowers/specs/2026-06-05-wave-id-implies-unlimited-max-tasks-design.md +0 -0
  19. {steerdev-1.1.25 → steerdev-1.1.27}/scripts/pre-commit-version-bump.sh +0 -0
  20. {steerdev-1.1.25 → steerdev-1.1.27}/snapshots/steerdev-agent-v1/Dockerfile +0 -0
  21. {steerdev-1.1.25 → steerdev-1.1.27}/snapshots/steerdev-agent-v1/start-agent.sh +0 -0
  22. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/__init__.py +0 -0
  23. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/__init__.py +0 -0
  24. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/activity.py +0 -0
  25. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/agents.py +0 -0
  26. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/canals.py +0 -0
  27. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/client.py +0 -0
  28. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/commands.py +0 -0
  29. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/configs.py +0 -0
  30. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/context.py +0 -0
  31. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/events.py +0 -0
  32. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/hooks.py +0 -0
  33. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/implementation_plan.py +0 -0
  34. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/merger.py +0 -0
  35. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/messages.py +0 -0
  36. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/prd.py +0 -0
  37. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/reports.py +0 -0
  38. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/runs.py +0 -0
  39. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/sessions.py +0 -0
  40. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/specs.py +0 -0
  41. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/tasks.py +0 -0
  42. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/workflow_runs.py +0 -0
  43. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/api/workflows.py +0 -0
  44. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/cli.py +0 -0
  45. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/config/__init__.py +0 -0
  46. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/config/models.py +0 -0
  47. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/config/platform.py +0 -0
  48. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/config/settings.py +0 -0
  49. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/evidence.py +0 -0
  50. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/evidence_assets.py +0 -0
  51. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/executor/__init__.py +0 -0
  52. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/executor/base.py +0 -0
  53. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/executor/claude.py +0 -0
  54. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/executor/stream.py +0 -0
  55. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/git_ops.py +0 -0
  56. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/handlers/__init__.py +0 -0
  57. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/handlers/prd.py +0 -0
  58. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/integration.py +0 -0
  59. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/prompt/__init__.py +0 -0
  60. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/prompt/builder.py +0 -0
  61. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/prompt/templates.py +0 -0
  62. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/prompt/workflow_template.py +0 -0
  63. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/py.typed +0 -0
  64. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/retry.py +0 -0
  65. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/runner.py +0 -0
  66. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/__init__.py +0 -0
  67. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/repo_setup.py +0 -0
  68. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/ci/canal-integration.yml +0 -0
  69. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/claude_md_section.md +0 -0
  70. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/settings.json +0 -0
  71. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-activity-skill/SKILL.md +0 -0
  72. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-canal-workflow-skill/SKILL.md +0 -0
  73. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-context-skill/SKILL.md +0 -0
  74. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-git-workflow-skill/SKILL.md +0 -0
  75. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-merge-into-canal-skill/SKILL.md +0 -0
  76. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-progress-logging-skill/SKILL.md +0 -0
  77. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-single-task-merge-skill/SKILL.md +0 -0
  78. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-specs-management-skill/SKILL.md +0 -0
  79. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-task-management-skill/SKILL.md +0 -0
  80. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/skills/steerdev-wave-tasks-merge-skill/SKILL.md +0 -0
  81. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/steerdev.yaml +0 -0
  82. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/setup/templates/worktrunk.config.toml +0 -0
  83. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/update_check.py +0 -0
  84. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/version.py +0 -0
  85. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workflow/__init__.py +0 -0
  86. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workflow/context.py +0 -0
  87. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workflow/executor.py +0 -0
  88. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workflow/memory.py +0 -0
  89. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workspace/__init__.py +0 -0
  90. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/workspace/tool_detection.py +0 -0
  91. {steerdev-1.1.25 → steerdev-1.1.27}/src/steerdev_agent/worktree.py +0 -0
  92. {steerdev-1.1.25 → steerdev-1.1.27}/tests/__init__.py +0 -0
  93. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_agent_loop_extended.py +0 -0
  94. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_agents_api.py +0 -0
  95. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_api_client.py +0 -0
  96. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_claude_executor.py +0 -0
  97. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_client_methods.py +0 -0
  98. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_commands_api.py +0 -0
  99. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_config.py +0 -0
  100. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_config_extended.py +0 -0
  101. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_conflict_mitigation.py +0 -0
  102. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_context_search.py +0 -0
  103. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_executor.py +0 -0
  104. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_git_ops.py +0 -0
  105. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_hooks_post_tool_use.py +0 -0
  106. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_platform_config.py +0 -0
  107. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_preparation_provenance.py +0 -0
  108. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_prompt.py +0 -0
  109. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_reports_client.py +0 -0
  110. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_resolve_max_tasks.py +0 -0
  111. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_retry.py +0 -0
  112. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_runner_merge_modes.py +0 -0
  113. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_runner_worktrees.py +0 -0
  114. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_stream_parser.py +0 -0
  115. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_tasks.py +0 -0
  116. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_tasks_update_branch.py +0 -0
  117. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_version.py +0 -0
  118. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_wave_scoped_fetch.py +0 -0
  119. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workflow_context.py +0 -0
  120. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workflow_memory.py +0 -0
  121. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workflow_prompt_template.py +0 -0
  122. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workflow_runs_api.py +0 -0
  123. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workspace.py +0 -0
  124. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_workspace_extended.py +0 -0
  125. {steerdev-1.1.25 → steerdev-1.1.27}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerdev
3
- Version: 1.1.25
3
+ Version: 1.1.27
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.25"
3
+ version = "1.1.27"
4
4
  description = "Backend task runner for steerdev.com - orchestrates CLI coding agents with activity reporting"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -582,10 +582,15 @@ class AgentLoop(CommandExecutor):
582
582
  # Step 2: Recover stale commands
583
583
  await self._recover_stale_commands()
584
584
 
585
- # Step 3: Start heartbeat
585
+ # Step 3: Reconcile .claude config (hooks) for this checkout, so a
586
+ # repo that predates newer steerdev hooks (e.g. the PostToolUse
587
+ # checkpoint-push hook) self-heals before any task launches Claude.
588
+ self._reconcile_settings()
589
+
590
+ # Step 4: Start heartbeat
586
591
  self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
587
592
 
588
- # Step 4: Main poll loop
593
+ # Step 5: Main poll loop
589
594
  await self._poll_loop()
590
595
 
591
596
  except AgentLoopError as e:
@@ -647,6 +652,23 @@ class AgentLoop(CommandExecutor):
647
652
  )
648
653
  await self._commands_client.mark_pending(cmd.id)
649
654
 
655
+ def _reconcile_settings(self) -> None:
656
+ """Re-apply steerdev-owned .claude/settings.json hooks for this checkout.
657
+
658
+ Single-project mode runs against an already-existing repo that never
659
+ went through project bootstrap, so its .claude config can be missing or
660
+ predate newer steerdev hooks. Reconciling here (idempotent, best-effort)
661
+ ensures the PostToolUse checkpoint-push hook is present before Claude
662
+ launches. A setup failure must never block the agent.
663
+ """
664
+ from steerdev_agent.setup.claude_setup import ClaudeSetup
665
+
666
+ try:
667
+ setup = ClaudeSetup(self.working_directory, install_target="project")
668
+ setup.setup_settings(force=False, include_hooks=True)
669
+ except Exception:
670
+ logger.exception(f"Failed to reconcile .claude settings for {self.working_directory}")
671
+
650
672
  async def _poll_loop(self) -> None:
651
673
  """Main loop: poll for commands and execute them."""
652
674
  logger.info("Entering poll loop")
@@ -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}")
@@ -322,6 +322,13 @@ class WorkspacePreparation:
322
322
  if wave_context and current_branch == target_branch:
323
323
  logger.info(f"Continuing wave on branch {target_branch}, fetching only")
324
324
  await _run_git("fetch", "origin", cwd=cwd, check=False)
325
+ # Persist provenance here too: a wave-continuation task arrives
326
+ # already on the branch, so it skips the create/checkout path
327
+ # below where provenance is normally written. Without this, the
328
+ # second+ task in a wave never records its branch on the task.
329
+ await persist_branch_provenance(
330
+ task_id=task_id, branch=target_branch, base_branch=default_branch
331
+ )
325
332
  return PreparationResult(
326
333
  success=True,
327
334
  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
 
@@ -72,6 +72,37 @@ class TestAgentLoopError:
72
72
  assert str(err) == "test error"
73
73
 
74
74
 
75
+ class TestAgentLoopReconcileSettings:
76
+ """Tests for single-project AgentLoop hook reconciliation."""
77
+
78
+ def _make_agent_loop(self, working_directory):
79
+ from steerdev_agent.agent_loop import AgentLoop
80
+
81
+ return AgentLoop(
82
+ project_id="proj-1",
83
+ agent_name="test-agent",
84
+ working_directory=working_directory,
85
+ api_key="test-key",
86
+ )
87
+
88
+ def test_reconciles_settings_at_working_directory(self, tmp_path):
89
+ agent = self._make_agent_loop(tmp_path)
90
+
91
+ with patch("steerdev_agent.setup.claude_setup.ClaudeSetup") as cls:
92
+ agent._reconcile_settings()
93
+
94
+ cls.assert_called_once_with(tmp_path, install_target="project")
95
+ cls.return_value.setup_settings.assert_called_once_with(force=False, include_hooks=True)
96
+
97
+ def test_reconcile_swallows_errors(self, tmp_path):
98
+ agent = self._make_agent_loop(tmp_path)
99
+
100
+ with patch("steerdev_agent.setup.claude_setup.ClaudeSetup") as cls:
101
+ cls.return_value.setup_settings.side_effect = RuntimeError("disk full")
102
+ # Must not raise — durability must never block the agent.
103
+ agent._reconcile_settings()
104
+
105
+
75
106
  class TestCommandExecutor:
76
107
  """Tests for CommandExecutor._execute_command dispatch logic."""
77
108
 
@@ -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
 
@@ -232,7 +232,10 @@ class TestWorkspacePreparationInPlace:
232
232
  return (0, "", "")
233
233
  return (0, "", "")
234
234
 
235
- with patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git):
235
+ with (
236
+ patch("steerdev_agent.workspace.preparation._run_git", side_effect=mock_run_git),
237
+ patch("steerdev_agent.workspace.preparation.persist_branch_provenance") as mock_persist,
238
+ ):
236
239
  result = await prep.prepare(sample_task, wave_ctx)
237
240
 
238
241
  assert result.success is True
@@ -240,6 +243,11 @@ class TestWorkspacePreparationInPlace:
240
243
  # Should NOT have checkout commands (stayed on branch)
241
244
  checkout_cmds = [c for c in calls if c[0] == "checkout"]
242
245
  assert len(checkout_cmds) == 0
246
+ # Wave-continuation must still record branch provenance on the task,
247
+ # even though it skips the create/checkout path.
248
+ mock_persist.assert_awaited_once_with(
249
+ task_id=sample_task["id"], branch="wave-a1b2c3", base_branch="main"
250
+ )
243
251
 
244
252
  @pytest.mark.asyncio
245
253
  async def test_dirty_repo_stashes(self, worktree_disabled, default_config, sample_task):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes