ctrlrelay 0.18.0__tar.gz → 0.19.0__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 (202) hide show
  1. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CHANGELOG.md +67 -1
  2. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/config/orchestrator.yaml.example +17 -0
  4. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/configuration.md +111 -0
  5. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/personalization.md +142 -10
  6. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/pyproject.toml +1 -1
  7. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/cli.py +47 -0
  8. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/code_review.py +16 -2
  9. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/config.py +190 -0
  10. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/worktree.py +280 -34
  11. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/manager.py +470 -6
  12. ctrlrelay-0.19.0/src/ctrlrelay/personalization/merge.py +533 -0
  13. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/dev.py +625 -261
  14. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/_publish_fakes.py +2 -0
  15. ctrlrelay-0.19.0/tests/test_dev_step_switches.py +1004 -0
  16. ctrlrelay-0.19.0/tests/test_dev_work_steps.py +214 -0
  17. ctrlrelay-0.19.0/tests/test_personalization_merge.py +1608 -0
  18. ctrlrelay-0.19.0/tests/test_relay_git_263.py +621 -0
  19. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_review_copy.py +24 -9
  20. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  21. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  22. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/dependabot.yml +0 -0
  24. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/build.yml +0 -0
  25. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/cla.yml +0 -0
  26. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/pages.yml +0 -0
  27. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/publish.yml +0 -0
  28. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/test.yml +0 -0
  29. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.gitignore +0 -0
  30. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CODE_OF_CONDUCT.md +0 -0
  31. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CONTRIBUTING.md +0 -0
  32. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/LICENSE +0 -0
  33. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/README.md +0 -0
  34. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/SECURITY.md +0 -0
  35. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/Gemfile +0 -0
  36. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/_config.yml +0 -0
  37. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/adding-a-machine.md +0 -0
  38. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/architecture.md +0 -0
  39. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/bridge.md +0 -0
  40. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/cli.md +0 -0
  41. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/development.md +0 -0
  42. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/feedback-loop.md +0 -0
  43. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/getting-started.md +0 -0
  44. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/index.md +0 -0
  45. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/operations.md +0 -0
  46. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/registered-mode.md +0 -0
  47. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  48. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  49. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/__init__.py +0 -0
  50. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  51. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  52. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/factory.py +0 -0
  53. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/handler.py +0 -0
  54. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  55. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  56. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  57. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/server.py +0 -0
  58. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  59. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/__init__.py +0 -0
  60. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/agent_env.py +0 -0
  61. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/archived.py +0 -0
  62. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/audit.py +0 -0
  63. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/binaries.py +0 -0
  64. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  65. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/claims.py +0 -0
  66. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  67. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  68. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/events.py +0 -0
  69. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github.py +0 -0
  70. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github_budget.py +0 -0
  71. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  72. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/job_claims.py +0 -0
  73. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/network.py +0 -0
  74. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/node_check.py +0 -0
  75. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/obs.py +0 -0
  76. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/poller.py +0 -0
  77. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  78. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  79. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/publish.py +0 -0
  80. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  81. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/redact.py +0 -0
  82. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/scheduler.py +0 -0
  83. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/state.py +0 -0
  84. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/usage.py +0 -0
  85. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  86. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/client.py +0 -0
  87. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/gh_protocol.py +0 -0
  88. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/install.py +0 -0
  89. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  90. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/paths.py +0 -0
  91. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  92. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/base.py +0 -0
  93. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  94. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  95. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/task.py +0 -0
  96. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/setup.py +0 -0
  97. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/__init__.py +0 -0
  98. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  99. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  100. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  101. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  102. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  103. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  104. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  105. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  106. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/__init__.py +0 -0
  107. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/base.py +0 -0
  108. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  109. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  110. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/__init__.py +0 -0
  111. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/fleet.py +0 -0
  112. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/health.py +0 -0
  113. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/links.py +0 -0
  114. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/numbers.py +0 -0
  115. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/server.py +0 -0
  116. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/static/index.html +0 -0
  117. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/status.py +0 -0
  118. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/transcript.py +0 -0
  119. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/waiting.py +0 -0
  120. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/__init__.py +0 -0
  121. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/_poller_fakes.py +0 -0
  122. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/conftest.py +0 -0
  123. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/gh_search_issues.json +0 -0
  124. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  125. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  126. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_backend_wiring.py +0 -0
  127. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_no_keys.py +0 -0
  128. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_routing.py +0 -0
  129. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_sandbox.py +0 -0
  130. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_archived.py +0 -0
  131. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_audit.py +0 -0
  132. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_binary_resolution.py +0 -0
  133. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_protocol.py +0 -0
  134. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_server.py +0 -0
  135. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_start_command.py +0 -0
  136. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_chat_machines.py +0 -0
  137. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_checkpoint.py +0 -0
  138. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_claim_reconciliation.py +0 -0
  139. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_ci_wait.py +0 -0
  140. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_dev.py +0 -0
  141. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_repos.py +0 -0
  142. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_secops.py +0 -0
  143. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_start.py +0 -0
  144. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_version.py +0 -0
  145. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_code_review.py +0 -0
  146. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_config.py +0 -0
  147. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dashboard_client.py +0 -0
  148. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dev_integration.py +0 -0
  149. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dev_pipeline.py +0 -0
  150. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatch_health.py +0 -0
  151. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatch_outcome_hook.py +0 -0
  152. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatcher.py +0 -0
  153. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_docs_site.py +0 -0
  154. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_event_emission.py +0 -0
  155. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_events.py +0 -0
  156. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_fleet.py +0 -0
  157. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_gh_protocol.py +0 -0
  158. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_github.py +0 -0
  159. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_github_budget.py +0 -0
  160. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_install.py +0 -0
  161. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_issue_outcome_announcement.py +0 -0
  162. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_job_claims.py +0 -0
  163. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_mattermost_handler.py +0 -0
  164. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_network.py +0 -0
  165. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_node_check.py +0 -0
  166. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_node_claims.py +0 -0
  167. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_notify_transport.py +0 -0
  168. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_obs.py +0 -0
  169. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_opencode_adapter.py +0 -0
  170. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_personalization.py +0 -0
  171. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pipeline_base.py +0 -0
  172. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_poller.py +0 -0
  173. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_poller_search.py +0 -0
  174. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_post_merge.py +0 -0
  175. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pr_verifier.py +0 -0
  176. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pr_watcher.py +0 -0
  177. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pre_dispatch_retry.py +0 -0
  178. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_publish_failure_retry.py +0 -0
  179. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_question_backoff.py +0 -0
  180. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_question_expiry.py +0 -0
  181. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_redact.py +0 -0
  182. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_relay_alert_off_lock.py +0 -0
  183. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_resume_prompt_contract.py +0 -0
  184. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_scheduler.py +0 -0
  185. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_secops_integration.py +0 -0
  186. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_secops_pipeline.py +0 -0
  187. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_setup.py +0 -0
  188. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_state.py +0 -0
  189. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_task_pipeline.py +0 -0
  190. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_telegram_handler.py +0 -0
  191. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_transport.py +0 -0
  192. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_transport_resolution.py +0 -0
  193. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  194. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_usage.py +0 -0
  195. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_health.py +0 -0
  196. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_links.py +0 -0
  197. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_numbers.py +0 -0
  198. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_server.py +0 -0
  199. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_status.py +0 -0
  200. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_transcript.py +0 -0
  201. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_waiting.py +0 -0
  202. {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.0] - 2026-11-03
11
+
12
+ ### Added
13
+
14
+ - `repos[].steps` sets each dev pipeline work step (`code`, `publish`,
15
+ `verify`, `review`) to `off`, `on` or `required`, and `repos[].max_fix_attempts`
16
+ caps the CI fix rounds. `required` stops the run on a failure, including a
17
+ reviewer that cannot run or CI that never finished; the PR stays open and the
18
+ run is announced as failed. Unset keys keep today's behaviour, and settings
19
+ the pipeline could not honour (`code: off`, `verify: on`, a review switch
20
+ that disagrees with `code_review.method`) are refused at load (#226).
21
+ - `mode: merge` for a personalization path keeps the live file real and, on
22
+ pull, writes only the top-level JSON keys listed in the local `merge_keys`,
23
+ so `~/.claude/settings.json` can carry fleet policy while the keys the app
24
+ writes on its own stay on that machine and no longer dirty the checkout. The
25
+ synced file cannot widen the list; it must be a JSON object without
26
+ duplicate keys; links (at the file or any directory under `$HOME` on the
27
+ way), non-regular and foreign-owned files are refused; writes are atomic at
28
+ mode 0600 or stricter and are redone once if the app writes the file
29
+ meanwhile; push refuses while the checkout copy differs from HEAD (`migrate
30
+ --reset-copy` drops the change) and a configuration with no merge entry
31
+ pushes exactly as before; logs name keys, never values, and a change to
32
+ `hooks`, `env`, `permissions`, `apiKeyHelper` or
33
+ `skipDangerousModePermissionPrompt` logs a warning. `ctrlrelay
34
+ personalization migrate` converts the old symlink once, on request, and
35
+ resets the checkout copy to the fleet's version; pull and auto-pull refuse
36
+ the symlink instead (#287).
37
+
38
+ ### Fixed
39
+
40
+ - A personalization pull on a machine still wired the old way (`~/.claude/settings.json`
41
+ a symlink into the checkout, with the app having written through it) failed
42
+ with "rebase hit conflicts" and never mentioned the fix. Pull and auto-pull
43
+ now add: run `ctrlrelay personalization migrate` (#287)
44
+
45
+ ### Security
46
+
47
+ - The relay's own git touches of agent-written trees no longer reach past
48
+ the agent sandbox (#263).
49
+ - Every `git worktree add` the relay runs now reads no global or system
50
+ git config and runs no hooks or fsmonitor, whether or not the sandbox is
51
+ on. A committed or planted attributes file therefore cannot select a
52
+ filter driver the operator's config defines. For that checkout this
53
+ drops `lfs.*` and `filter.*` drivers, post-checkout hooks,
54
+ `safe.directory`, `core.autocrlf`/`core.eol`, `core.longpaths` and
55
+ `url.*.insteadOf` (not needed: the add does no fetch).
56
+ - A repository using git-lfs now gives the agent pointer files. None of
57
+ the repositories the relay currently works on uses LFS: 0 of 90 bare
58
+ clones have `filter=lfs` in HEAD's `.gitattributes`.
59
+ - The bare clone's own config is still read. The relay writes it, and the
60
+ sandbox keeps it read-only.
61
+ - The `CLAUDE.md` exclude line is written only to the worktree's git
62
+ directory found from the path the relay made (never from the
63
+ worktree's `.git` file): `<clone>.git/worktrees/<basename>[N]`, whose
64
+ back-pointer must name this worktree. It is opened without following
65
+ any link, a hard-linked file is refused, and every refusal is logged
66
+ and skipped rather than failing the resume.
67
+ - The reviewer's base branch and its commit come from GitHub rather than
68
+ the bare clone, are pinned in the review copy, and are named in the
69
+ review comment.
70
+
10
71
  ## [0.18.0] - 2026-11-03
11
72
 
12
73
  ### Added
@@ -38,6 +99,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
99
 
39
100
  ### Changed
40
101
 
102
+ - Internal, no behaviour change: the dev pipeline's work (code, publish,
103
+ verify, review) runs as a fixed, ordered list of steps inside a frame that
104
+ keeps the lock, claim, worktree, cleanup and claim reconciliation as they
105
+ were. Groundwork for per-repo step switches (refs #226)
41
106
  - The subprocess machinery every CLI-driven adapter shares — the timeout, the
42
107
  kill on cancel, the replacing decode, the checkpoint read and #204's outcome
43
108
  report on every exit path — moved from `ClaudeDispatcher` to a
@@ -2192,7 +2257,8 @@ pipeline).
2192
2257
  per-phase implementation plans (Phase 0 through Phase 4).
2193
2258
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2194
2259
 
2195
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...HEAD
2260
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.19.0...HEAD
2261
+ [0.19.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.18.0...v0.19.0
2196
2262
  [0.18.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...v0.18.0
2197
2263
  [0.17.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...v0.17.1
2198
2264
  [0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -254,3 +254,20 @@ repos: []
254
254
  # # generated while blocked in this call, so a slow default here
255
255
  # # just adds idle wall-clock time. Defaults to 600 (10 minutes).
256
256
  # ci_wait_timeout_seconds: 600
257
+ # # How strict each dev pipeline work step is for this repo. The
258
+ # # order (code, publish, verify, review) is fixed. Each key is
259
+ # # off, on or required; leave a key out for today's behaviour.
260
+ # # required: a failure stops the run, and nothing skips the step
261
+ # # (a reviewer that cannot run is not a pass, and CI
262
+ # # that never finished is not a pass)
263
+ # # on: a failure is reported and the PR is handed over
264
+ # # off: the step does not run
265
+ # # code and publish accept only required. verify accepts off or
266
+ # # required. review: off is the same as code_review.method: off;
267
+ # # set both only if they agree.
268
+ # steps:
269
+ # verify: required
270
+ # review: required
271
+ # # Fix rounds for a PR whose CI is red or whose branch conflicts,
272
+ # # before the run fails. Defaults to 3; 0 means none.
273
+ # max_fix_attempts: 3
@@ -658,6 +658,8 @@ repos:
658
658
  | `agent_type` | string | no | `null` | Which agent backend runs this repo's sessions. `null` means `agent.type`. See [agent backends](#agent-backends). A value no adapter answers to stops the daemon at startup, naming this repo. |
659
659
  | `automation` | object | no | (defaults) | See [automation](#repos-automation). |
660
660
  | `code_review` | object | no | (defaults) | Review run over an agent branch before its PR is handed over. See [code_review](#code_review). |
661
+ | `steps` | object | no | (all unset) | How strict each dev pipeline work step is. See [steps](#repossteps). |
662
+ | `max_fix_attempts` | integer ≥ 0 | no | `3` | Fix rounds the dev pipeline asks for when a PR's CI is red or its branch conflicts, before the run fails. `0` means none. See [steps](#repossteps). |
661
663
  | `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
662
664
 
663
665
  ### repos[].automation
@@ -856,6 +858,109 @@ repos:
856
858
  - This does not change the overall session timeout (`agent.default_timeout_seconds`,
857
859
  default 1800s) — it only bounds the one `ci wait` call.
858
860
 
861
+ ### repos[].steps
862
+
863
+ The dev pipeline does its work in a fixed order: **code** (the agent run and
864
+ any questions it asks), **publish** (the relay pushes the branch and opens the
865
+ pull request), **verify** (wait for CI and mergeability, asking the agent to
866
+ fix what fails, up to `max_fix_attempts` rounds) and **review** (the
867
+ configured reviewer reads the pushed commit and posts its findings). The order
868
+ is not configurable. `steps` says how strict each step is for this repo.
869
+ Only the dev pipeline reads it. The task and secops pipelines have no
870
+ switchable steps (see [below](#task-and-secops-have-no-step-switches)), so
871
+ `steps` never changes how a `task`-labelled issue or a secops sweep runs.
872
+
873
+ ```yaml
874
+ repos:
875
+ - name: "your-org/your-repo"
876
+ steps:
877
+ verify: required
878
+ review: required
879
+ max_fix_attempts: 2
880
+ ```
881
+
882
+ | Mode | Meaning |
883
+ |---|---|
884
+ | `off` | The step does not run. |
885
+ | `on` | A failure is reported and the run continues: the PR is handed over. |
886
+ | `required` | A failure stops the run, and no route skips the step. A step that cannot run has not passed. |
887
+ | (unset) | What the pipeline did before these switches. See the table below. |
888
+
889
+ | Step | Accepts | Unset means |
890
+ |---|---|---|
891
+ | `code` | `required` | A failed or crashed agent run stops the run. Same as `required`. |
892
+ | `publish` | `required` | A refused or failed push stops the run (the branch is kept). Same as `required`. |
893
+ | `verify` | `off`, `required` | CI still red after the fix rounds stops the run. CI that is still pending when the wait times out hands the PR over. |
894
+ | `review` | `off`, `on`, `required` | `on`: a review that did not post is announced as `NO REVIEW ON THIS PR` and the PR is handed over. |
895
+
896
+ `code` and `publish` cannot be `off` or `on`: a dev run without them has
897
+ nothing to hand over, so the config refuses those values at load instead of
898
+ accepting and ignoring them. `verify: on` is refused too: it would announce a
899
+ PR as ready while its CI is red, with no line in the announcement saying so.
900
+ Unknown step names (a typo such as `verfy`) are refused as well. A bare `on`
901
+ or `off` works unquoted.
902
+
903
+ **`verify: required`** also fails the run when the CI wait times out, before
904
+ or after a fix round. (Checks that cannot be read at all fail the run in every
905
+ mode that waits on CI, not only under `required`.)
906
+ **`verify: off`** skips the CI wait and the fix rounds; the review still runs.
907
+ The PR is announced as ready without anyone having looked at its CI — the
908
+ same thing `verify: on` is refused for, chosen deliberately here by turning
909
+ verify off. The status page has no CI events for the run; `pr_opened` is
910
+ still recorded, once, when the PR is published.
911
+
912
+ **`review: required`** passes only when the review's findings were posted on
913
+ the PR. A reviewer that cannot start, one that ran without reading the diff, a
914
+ comment that failed to post, a copy of the commit that could not be made, and
915
+ an exception inside the review are all failures. It is about the review
916
+ having happened, not about what it found. **`review: off`** is the same as
917
+ `code_review.method: off`, and loads as that.
918
+
919
+ Two settings for one reviewer must agree, or the config refuses to load:
920
+
921
+ - `code_review.method: off` with `steps.review: on` or `required`;
922
+ - `steps.review: off` with an explicit `code_review.method: cli`;
923
+ - `steps.review: required` with `code_review.comment_on_pr: false`, since the
924
+ findings would never reach the PR.
925
+
926
+ `code_review.method: off` on its own keeps working as before.
927
+
928
+ **What the operator sees when a required step stops a run after the PR
929
+ exists.** The ordinary failure path, unchanged:
930
+
931
+ - the PR stays open on GitHub, and the relay keeps watching it for a merge;
932
+ - the session is marked `failed`; the worktree is removed and the pushed
933
+ branch kept;
934
+ - the claim on the issue is retracted with a comment that names the PR and
935
+ the reason (for example `PR #42 not handed over: review is required for
936
+ your-org/your-repo and did not pass`);
937
+ - chat gets `❌ Failed on #N: …`, never `✅ PR ready`.
938
+
939
+ A resumed session (an answer that arrived after the run stopped to ask) goes
940
+ through the same publish and verify steps. It did not run the review before
941
+ these switches, and with `review` unset or `on` it still does not; with
942
+ `review: required` it does.
943
+
944
+ #### Task and secops have no step switches
945
+
946
+ Each of their steps already behaves as `required` or as a best-effort side
947
+ effect, and neither `off` nor `on` would mean something the relay can honour:
948
+
949
+ - **Task, the agent run.** A task run without it has nothing to report.
950
+ Only `required` would make sense, and that is what it already does.
951
+ - **Task, posting the report.** A DONE with no report, or a report the relay
952
+ cannot post, fails the run today. `off` would lose the report: chat gets
953
+ only the one-line summary. `on` would announce `✅ Task done` and keep the
954
+ claim on an issue with no report, which looks the same as a crash.
955
+ - **Secops, the sweep.** One agent session reads the alerts and pull requests
956
+ and merges what the policy allows. The merges happen inside that session,
957
+ not in a relay step, so a switch could not hold them back. The switch for
958
+ them already exists: `automation.dependabot_patch`, `dependabot_minor` and
959
+ `dependabot_major` (`auto`, `ask` or `never`).
960
+ - **Secops, reading earlier answers.** If the 30-day decision history cannot
961
+ be read, the sweep runs without it. `off` would drop the operator's answers
962
+ and let the policy merge a pull request they said to leave open.
963
+
859
964
  ## schedules
860
965
 
861
966
  In-process cron jobs run by the poller daemon. All expressions are
@@ -893,6 +998,10 @@ personalization:
893
998
  target: "~/.claude/CLAUDE.md"
894
999
  - source: "global/skills/"
895
1000
  target: "~/.claude/skills/"
1001
+ - source: "global/settings.json"
1002
+ target: "~/.claude/settings.json"
1003
+ mode: merge
1004
+ merge_keys: [model, outputStyle, hooks]
896
1005
  - source: "claude-memory/${PROJECT}/"
897
1006
  target: "~/.claude/projects/${PROJECT_ENCODED}/memory/"
898
1007
  project_scoped: true
@@ -921,6 +1030,8 @@ agree.
921
1030
  | `source` | string | **yes** | Path inside the personalization repo. Trailing slash means directory. |
922
1031
  | `target` | string | **yes** | On-disk path the symlink will be created at. Supports `${HOME}` and (when `project_scoped`) `${PROJECT}`, `${PROJECT_ENCODED}`, `${PROJECT_LOCAL}`, `${PROJECT_PARENT}`. |
923
1032
  | `project_scoped` | bool | no (default `false`) | When `true`, the entry expands once per repo in `repos:`, with the `${PROJECT_*}` placeholders resolved against that repo's `local_path`. |
1033
+ | `mode` | string | no (default `"symlink"`) | `symlink` links the target into the checkout. `merge` keeps the target a real file and, on pull, writes into it only the top-level JSON keys in `merge_keys`; for files an app also rewrites, such as `~/.claude/settings.json`. A single file only, not `project_scoped`. See [Files an app rewrites in place]({{ '/personalization/' | relative_url }}). |
1034
+ | `merge_keys` | list of strings | with `mode: merge` | The top-level keys the fleet owns in that file. Local config only: the synced file cannot add to it. Required and non-empty for `merge`; not allowed for `symlink`. |
924
1035
 
925
1036
  The `${PROJECT}` slug uses **`<owner>--<repo>`** with a double
926
1037
  hyphen so `a-b/c` and `a/b-c` produce different slugs. `${PROJECT_ENCODED}`
@@ -301,20 +301,152 @@ refuse-on-dirty rail keeps things safe — the daemon won't rebase under
301
301
  your unsaved work and says so at error level — but your edit doesn't
302
302
  reach other machines until you push.
303
303
 
304
- ### Files an app rewrites in place (`settings.json`)
304
+ ### Files an app rewrites in place (`settings.json`): `mode: merge`
305
305
 
306
306
  If a target such as `~/.claude/settings.json` is a symlink into the
307
307
  checkout, the app that owns it writes straight into a tracked file: any
308
308
  per-viewer preference it records (and a mere key reordering on rewrite)
309
- dirties the tree and blocks every later pull.
310
-
311
- **How to handle this is not decided yet** (#287). Two things are known
312
- not to work: committing the app-written keys (one machine's preference
313
- would reach every machine as if it were policy), and resetting the file
314
- on a loop (the app writes it again). One way out is for a machine whose
315
- live file carries keys the synced one lacks to keep a real file instead
316
- of the symlink. Until a decision lands, the `BLOCKED` line above is the
317
- signal that a machine is stuck this way.
309
+ dirties the tree and blocks every later pull. Committing those keys is
310
+ wrong too, because one machine's preference would reach every machine
311
+ as if it were policy.
312
+
313
+ So for such a file, declare the entry with `mode: merge` and list the
314
+ top-level keys the fleet owns:
315
+
316
+ ```yaml
317
+ personalization:
318
+ paths:
319
+ - source: "global/settings.json"
320
+ target: "~/.claude/settings.json"
321
+ mode: merge
322
+ merge_keys: [model, outputStyle, hooks, teammateMode,
323
+ remoteControlAtStartup, skipDangerousModePermissionPrompt,
324
+ idleCompaction]
325
+ ```
326
+
327
+ The live file is then a **real file on this machine**, never a symlink.
328
+ On `init`, `pull` and the daemon's auto-pull, ctrlrelay writes into it
329
+ only the listed keys that the synced file carries, and reports what it
330
+ did on the same line as the pull: `settings: wrote model, outputStyle`,
331
+ or `settings: in-sync`. Everything else in the live file, including
332
+ whatever the app wrote on its own, is left as it is and never reaches
333
+ the checkout.
334
+
335
+ The rules, all enforced and tested:
336
+
337
+ - **The key list is local.** `merge_keys` lives in `orchestrator.yaml`
338
+ on each machine, not in the synced repo, so a push to that repo cannot
339
+ widen what it controls. A key in the synced file that is not listed
340
+ is not written. (Keep `orchestrator.yaml` itself out of the synced
341
+ repo, or this guarantee is gone.)
342
+ - **An owned key missing upstream is left alone**, not deleted: absent
343
+ is not the same as "remove it". To change a value fleet-wide, set it.
344
+ - **Bad input is refused.** The synced file must parse as a JSON object
345
+ with no duplicate keys, at any depth, and so must the live file (an
346
+ empty live file counts as `{}`). Nesting too deep to parse is refused
347
+ the same way. Anything else leaves the live file untouched and logs an
348
+ error.
349
+ - **Everything stays under `$HOME`.** The live file **and the
350
+ personalization checkout** must both be under your home directory; a
351
+ path outside it is refused with `is not under $HOME`. (The default
352
+ checkout, `~/.ctrlrelay/personalization`, is.)
353
+ - **Links and odd files are refused.** Directories are opened one
354
+ component at a time without following links (`O_DIRECTORY |
355
+ O_NOFOLLOW`), starting at the resolved `$HOME`, so a home reached
356
+ through an alias (macOS `/var` -> `/private/var`) works. The read, the
357
+ temp file and the rename all go through that directory handle. So a symlink at the file **or at any directory under `$HOME`
358
+ on the way to it** is refused, and its target is never opened. A
359
+ symlinked `~/.claude` is therefore refused too. Both files must be
360
+ regular files owned by you, so a FIFO or someone else's file is
361
+ refused as well. The live path, with its directories resolved, must sit outside
362
+ the checkout.
363
+ - **Writes are atomic.** Temp file in the same directory, fsync, rename
364
+ over the live path; mode `0600`, or the existing mode if it is
365
+ stricter.
366
+ - **A concurrent app write is not lost silently.** Just before the
367
+ rename, the live file is checked to still be what was read (device,
368
+ inode, size, mtime). If the app wrote it in between, the merge is
369
+ redone once from the new content; if it changed again, the pull
370
+ refuses and the app's write stands. Known limit: a write landing in
371
+ the few instructions between that last check and the rename itself
372
+ can still be overwritten, as can one that changes neither size nor
373
+ mtime. No user-space check can close that window without the app
374
+ taking a lock.
375
+ - **Values too deep to handle are refused**, whether the parser rejects
376
+ them or they parse but are too deep to compare or write back.
377
+ - **What keeps app-written keys out of the repo, exactly.** The live
378
+ file is a real file **outside the checkout**, so nothing the app
379
+ writes to it is in the git working tree at all. That is the guarantee.
380
+ - **Push refuses a changed checkout copy.** If a merge entry's checkout
381
+ copy differs from `HEAD` in any way (modified, staged, deleted,
382
+ renamed away, or present but untracked), `push` refuses before
383
+ staging anything and leaves the file alone:
384
+ `refusing to push: global/settings.json in the checkout differs from
385
+ HEAD. This file is fleet policy; to change it for every machine,
386
+ commit it yourself with git in the checkout; to drop the local change,
387
+ run ctrlrelay personalization migrate --reset-copy`.
388
+ After staging, push also unstages every merge-mode file and checks the
389
+ index (ignoring case where git does), refusing if one is still staged.
390
+ These push checks are a **backstop against accidental staging**.
391
+ - **What this does not cover: deliberate local edits in the checkout.**
392
+ Anyone who can write the checkout can already publish anything from
393
+ it, so these are out of scope by design, not gaps to close:
394
+ - renaming the copy (`git mv global/settings.json ...` and committing);
395
+ - copying it elsewhere in a synced directory (`cp global/settings.json
396
+ global/other.json`), which pushes the copy as an ordinary file;
397
+ - hiding a change from git with `git update-index --assume-unchanged`
398
+ or `--skip-worktree` on the copy;
399
+ - committing to the checkout by hand (`git commit`), which `push` then
400
+ publishes like any other commit.
401
+ - **Whatever is committed in the checkout goes to every machine.** To
402
+ change fleet settings, edit `global/settings.json` in the checkout and
403
+ `git commit` it yourself; `push` publishes that commit like any other,
404
+ and every machine applies it within its own `merge_keys`. ctrlrelay
405
+ does not inspect such a commit, so never commit a value that belongs
406
+ to one machine.
407
+ - **Only HEAD's fleet values reach the live file.** A merge (on pull,
408
+ `init` or `migrate`) is refused while the checkout copy differs from
409
+ `HEAD`, and `migrate --reset-copy` resets the copy *before* merging,
410
+ so a local edit to a fleet key is never applied to the live file.
411
+ If git cannot answer whether the copy differs, push and merge refuse
412
+ (`could not check global/settings.json: ...`) rather than assume.
413
+ - **Without merge entries nothing changes.** A configuration with no
414
+ `mode: merge` entry stages and commits on `push` exactly as before.
415
+ - **Logs name keys, never values** (a value can be a secret under
416
+ `env`). A pull that changes `hooks`, `env`, `permissions`,
417
+ `apiKeyHelper` or `skipDangerousModePermissionPrompt` logs a
418
+ **warning** naming the key, so a surprise change is visible.
419
+
420
+ A refusal makes the pull fail (`settings: REFUSED (...)`), so a machine
421
+ that cannot apply fleet settings says so rather than looking synced.
422
+
423
+ #### Moving off the old symlink: `personalization migrate`
424
+
425
+ A machine wired before this has `~/.claude/settings.json` as a symlink
426
+ into the checkout. `pull` and auto-pull **refuse** that link and say so;
427
+ they never convert it themselves. Convert it once, explicitly:
428
+
429
+ ```bash
430
+ ctrlrelay personalization migrate
431
+ ```
432
+
433
+ This replaces the link with a real file holding the same content (so
434
+ anything the app or you wrote through it stays on this machine). Once
435
+ the new live file is confirmed to match, it resets the checkout copy to
436
+ `HEAD`, the fleet's version, so the tree is clean and those local keys
437
+ can never be pushed; the output names the keys it reset, never their
438
+ values. Then it merges. A link that points anywhere other than this
439
+ checkout's source is refused.
440
+
441
+ `ctrlrelay personalization migrate --reset-copy` additionally drops any
442
+ remaining local change to a merge entry's checkout copy (resetting it to
443
+ `HEAD`; the output names the keys, never the values). It is the way out
444
+ of a push refused because that copy changed. It refuses while the live
445
+ path is still the old symlink, since the copy is then the live file.
446
+
447
+ Anything outside ctrlrelay that recreates the symlink (a converge
448
+ script, a dotfiles installer) must stop doing so for this file, or the
449
+ next pull will refuse again.
318
450
 
319
451
  ### Git identity
320
452
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.18.0"
3
+ version = "0.19.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -2512,6 +2512,8 @@ def run_dev(
2512
2512
  contexts_dir=config.paths.contexts,
2513
2513
  ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
2514
2514
  code_review=repo_config.code_review,
2515
+ max_fix_attempts=repo_config.max_fix_attempts,
2516
+ steps=repo_config.steps,
2515
2517
  )
2516
2518
 
2517
2519
  try:
@@ -3263,6 +3265,8 @@ def poller_start(
3263
3265
  contexts_dir=config.paths.contexts,
3264
3266
  ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
3265
3267
  code_review=repo_config.code_review,
3268
+ max_fix_attempts=repo_config.max_fix_attempts,
3269
+ steps=repo_config.steps,
3266
3270
  claims=claims,
3267
3271
  )
3268
3272
 
@@ -3814,6 +3818,9 @@ def poller_start(
3814
3818
  state_db=state_db,
3815
3819
  transport=sweeper_transport,
3816
3820
  contexts_dir=config.paths.contexts,
3821
+ max_fix_attempts=repo_cfg.max_fix_attempts,
3822
+ code_review=repo_cfg.code_review,
3823
+ steps=repo_cfg.steps,
3817
3824
  )
3818
3825
  elif pipeline_name == "task":
3819
3826
  result = await resume_task_from_pending(
@@ -4740,6 +4747,46 @@ def personalization_pull(
4740
4747
  raise typer.Exit(1)
4741
4748
 
4742
4749
 
4750
+ @personalization_app.command("migrate")
4751
+ def personalization_migrate(
4752
+ config_path: str | None = typer.Option(
4753
+ None,
4754
+ "--config",
4755
+ "-c",
4756
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
4757
+ ),
4758
+ reset_copy: bool = typer.Option(
4759
+ False,
4760
+ "--reset-copy",
4761
+ help=(
4762
+ "Also drop any local change to a merge entry's checkout copy, "
4763
+ "resetting it to HEAD (the fleet's version). The way out of a "
4764
+ "push refused because that copy changed."
4765
+ ),
4766
+ ),
4767
+ ) -> None:
4768
+ """Turn each `mode: merge` live path that is still the old symlink
4769
+ into this checkout into a real file, keeping its current content.
4770
+
4771
+ One-time and explicit: `pull` and the daemon's auto-pull refuse a
4772
+ symlinked merge target and never migrate it themselves.
4773
+ """
4774
+ from ctrlrelay.personalization.manager import PersonalizationError
4775
+
4776
+ mgr = _make_personalization_manager(config_path)
4777
+ try:
4778
+ outcomes = mgr.migrate(reset_copy=reset_copy)
4779
+ except PersonalizationError as e:
4780
+ console.print(f"[red]Migrate failed:[/red] {e}")
4781
+ raise typer.Exit(1)
4782
+ if not outcomes:
4783
+ console.print("no `mode: merge` entries configured")
4784
+ for o in outcomes:
4785
+ console.print(o.describe(), markup=False)
4786
+ if any(o.refused for o in outcomes):
4787
+ raise typer.Exit(1)
4788
+
4789
+
4743
4790
  @app.command("setup")
4744
4791
  def setup(
4745
4792
  owner: list[str] = typer.Option(
@@ -160,22 +160,36 @@ def _dedupe_trailing_echo(text: str) -> str:
160
160
 
161
161
 
162
162
  def format_review_comment(
163
- outcome: CodeReviewOutcome, *, worktree_path: Path | None = None
163
+ outcome: CodeReviewOutcome,
164
+ *,
165
+ worktree_path: Path | None = None,
166
+ head_sha: str | None = None,
167
+ base_branch: str | None = None,
168
+ base_sha: str | None = None,
164
169
  ) -> str:
165
170
  """Render findings for a PR comment.
166
171
 
167
172
  Absolute worktree paths are stripped: they name a directory on the
168
173
  orchestrator's disk that means nothing to a reader and will not exist
169
174
  by the time anyone clicks.
175
+
176
+ When the commits are known they are named, so a reader can tell what
177
+ was diffed against what: the base is taken from GitHub, and a later
178
+ push or a moved base is visible as a mismatch.
170
179
  """
171
180
  body = _dedupe_trailing_echo(extract_verdict(outcome.output))
172
181
  if worktree_path is not None:
173
182
  body = body.replace(f"{worktree_path}/", "").replace(str(worktree_path), "")
183
+ scope = ""
184
+ if head_sha and base_branch and base_sha:
185
+ scope = (
186
+ f"Commit `{head_sha}` against `{base_branch}` at `{base_sha}`.\n\n"
187
+ )
174
188
  return (
175
189
  "## Automated code review\n\n"
176
190
  "_Unverified: this is automated output over the branch, not a "
177
191
  "confirmation that anything was reviewed. Weigh it as one "
178
- "opinion._\n\n" + (body or "_No findings reported._")
192
+ "opinion._\n\n" + scope + (body or "_No findings reported._")
179
193
  )
180
194
 
181
195