ctrlrelay 0.17.1__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.17.1 → ctrlrelay-0.19.0}/CHANGELOG.md +156 -6
  2. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/config/orchestrator.yaml.example +67 -4
  4. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/configuration.md +231 -1
  5. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/operations.md +17 -0
  6. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/personalization.md +166 -7
  7. ctrlrelay-0.19.0/docs/registered-mode.md +476 -0
  8. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/pyproject.toml +1 -1
  9. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/cli.py +106 -14
  10. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/__init__.py +8 -0
  11. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/code_review.py +16 -2
  12. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/config.py +241 -6
  13. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatcher.py +857 -320
  14. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/node_check.py +16 -2
  15. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/worktree.py +390 -33
  16. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/manager.py +541 -12
  17. ctrlrelay-0.19.0/src/ctrlrelay/personalization/merge.py +533 -0
  18. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/dev.py +625 -198
  19. ctrlrelay-0.19.0/src/ctrlrelay/web/numbers.py +275 -0
  20. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/static/index.html +103 -0
  21. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/status.py +32 -0
  22. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/_publish_fakes.py +2 -0
  23. ctrlrelay-0.19.0/tests/test_agent_backend_wiring.py +264 -0
  24. ctrlrelay-0.19.0/tests/test_agent_routing.py +397 -0
  25. ctrlrelay-0.19.0/tests/test_dev_step_switches.py +1004 -0
  26. ctrlrelay-0.19.0/tests/test_dev_work_steps.py +214 -0
  27. ctrlrelay-0.19.0/tests/test_opencode_adapter.py +1044 -0
  28. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_personalization.py +69 -3
  29. ctrlrelay-0.19.0/tests/test_personalization_merge.py +1608 -0
  30. ctrlrelay-0.19.0/tests/test_relay_git_263.py +621 -0
  31. ctrlrelay-0.19.0/tests/test_review_copy.py +421 -0
  32. ctrlrelay-0.19.0/tests/test_web_numbers.py +426 -0
  33. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_server.py +1 -1
  34. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_status.py +1 -1
  35. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  37. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  38. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/dependabot.yml +0 -0
  39. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/build.yml +0 -0
  40. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/cla.yml +0 -0
  41. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/pages.yml +0 -0
  42. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/publish.yml +0 -0
  43. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.github/workflows/test.yml +0 -0
  44. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/.gitignore +0 -0
  45. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/CODE_OF_CONDUCT.md +0 -0
  46. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/CONTRIBUTING.md +0 -0
  47. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/LICENSE +0 -0
  48. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/README.md +0 -0
  49. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/SECURITY.md +0 -0
  50. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/Gemfile +0 -0
  51. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/_config.yml +0 -0
  52. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/adding-a-machine.md +0 -0
  53. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/architecture.md +0 -0
  54. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/bridge.md +0 -0
  55. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/cli.md +0 -0
  56. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/development.md +0 -0
  57. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/feedback-loop.md +0 -0
  58. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/getting-started.md +0 -0
  59. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/index.md +0 -0
  60. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  61. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  62. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/__init__.py +0 -0
  63. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  64. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  65. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/factory.py +0 -0
  66. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/handler.py +0 -0
  67. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  68. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  69. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  70. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/server.py +0 -0
  71. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  72. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/agent_env.py +0 -0
  73. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/archived.py +0 -0
  74. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/audit.py +0 -0
  75. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/binaries.py +0 -0
  76. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  77. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/claims.py +0 -0
  78. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  79. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/events.py +0 -0
  80. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github.py +0 -0
  81. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github_budget.py +0 -0
  82. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  83. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/job_claims.py +0 -0
  84. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/network.py +0 -0
  85. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/obs.py +0 -0
  86. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/poller.py +0 -0
  87. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  88. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  89. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/publish.py +0 -0
  90. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  91. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/redact.py +0 -0
  92. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/scheduler.py +0 -0
  93. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/state.py +0 -0
  94. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/core/usage.py +0 -0
  95. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  96. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/client.py +0 -0
  97. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/gh_protocol.py +0 -0
  98. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/install.py +0 -0
  99. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  100. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/paths.py +0 -0
  101. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  102. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/base.py +0 -0
  103. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  104. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  105. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/task.py +0 -0
  106. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/setup.py +0 -0
  107. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/__init__.py +0 -0
  108. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  109. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  110. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  111. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  112. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  113. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  114. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  115. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  116. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/__init__.py +0 -0
  117. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/base.py +0 -0
  118. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  119. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  120. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/__init__.py +0 -0
  121. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/fleet.py +0 -0
  122. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/health.py +0 -0
  123. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/links.py +0 -0
  124. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/server.py +0 -0
  125. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/transcript.py +0 -0
  126. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/src/ctrlrelay/web/waiting.py +0 -0
  127. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/__init__.py +0 -0
  128. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/_poller_fakes.py +0 -0
  129. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/conftest.py +0 -0
  130. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/gh_search_issues.json +0 -0
  131. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  132. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  133. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_agent_no_keys.py +0 -0
  134. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_agent_sandbox.py +0 -0
  135. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_archived.py +0 -0
  136. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_audit.py +0 -0
  137. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_binary_resolution.py +0 -0
  138. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_protocol.py +0 -0
  139. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_server.py +0 -0
  140. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_bridge_start_command.py +0 -0
  141. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_chat_machines.py +0 -0
  142. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_checkpoint.py +0 -0
  143. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_claim_reconciliation.py +0 -0
  144. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_ci_wait.py +0 -0
  145. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_dev.py +0 -0
  146. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_repos.py +0 -0
  147. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_secops.py +0 -0
  148. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_start.py +0 -0
  149. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_cli_version.py +0 -0
  150. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_code_review.py +0 -0
  151. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_config.py +0 -0
  152. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dashboard_client.py +0 -0
  153. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dev_integration.py +0 -0
  154. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dev_pipeline.py +0 -0
  155. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatch_health.py +0 -0
  156. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatch_outcome_hook.py +0 -0
  157. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_dispatcher.py +0 -0
  158. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_docs_site.py +0 -0
  159. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_event_emission.py +0 -0
  160. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_events.py +0 -0
  161. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_fleet.py +0 -0
  162. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_gh_protocol.py +0 -0
  163. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_github.py +0 -0
  164. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_github_budget.py +0 -0
  165. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_install.py +0 -0
  166. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_issue_outcome_announcement.py +0 -0
  167. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_job_claims.py +0 -0
  168. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_mattermost_handler.py +0 -0
  169. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_network.py +0 -0
  170. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_node_check.py +0 -0
  171. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_node_claims.py +0 -0
  172. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_notify_transport.py +0 -0
  173. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_obs.py +0 -0
  174. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pipeline_base.py +0 -0
  175. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_poller.py +0 -0
  176. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_poller_search.py +0 -0
  177. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_post_merge.py +0 -0
  178. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pr_verifier.py +0 -0
  179. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pr_watcher.py +0 -0
  180. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_pre_dispatch_retry.py +0 -0
  181. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_publish_failure_retry.py +0 -0
  182. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_question_backoff.py +0 -0
  183. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_question_expiry.py +0 -0
  184. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_redact.py +0 -0
  185. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_relay_alert_off_lock.py +0 -0
  186. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_resume_prompt_contract.py +0 -0
  187. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_scheduler.py +0 -0
  188. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_secops_integration.py +0 -0
  189. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_secops_pipeline.py +0 -0
  190. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_setup.py +0 -0
  191. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_state.py +0 -0
  192. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_task_pipeline.py +0 -0
  193. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_telegram_handler.py +0 -0
  194. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_transport.py +0 -0
  195. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_transport_resolution.py +0 -0
  196. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  197. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_usage.py +0 -0
  198. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_health.py +0 -0
  199. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_links.py +0 -0
  200. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_transcript.py +0 -0
  201. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_web_waiting.py +0 -0
  202. {ctrlrelay-0.17.1 → ctrlrelay-0.19.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,155 @@ 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
+
71
+ ## [0.18.0] - 2026-11-03
72
+
73
+ ### Added
74
+
75
+ - A third agent backend: `agent.type: "opencode"` runs sessions through
76
+ `opencode run --format json --dangerously-skip-permissions` — the spelling
77
+ every opencode from 1.4 to 2.x accepts; its `--auto` alias exists only from
78
+ 1.17.12 — which can target a self-hosted OpenAI-compatible endpoint
79
+ (ollama, llama.cpp, LM Studio, MLX) and so lets the dev pipeline run on a
80
+ local model instead of a hosted API. The endpoint, its credential and the
81
+ model list stay in opencode's own config; ctrlrelay names the model with
82
+ `agent.opencode.model` (`provider/model`) and the binary with
83
+ `agent.opencode.binary` — its own block, because `agent.binary` is claude's
84
+ and most configs set it to the literal `"claude"`.
85
+ - `repos[].agent_type` opts a single repository into another backend, with
86
+ `agent.type` the default for every other one. Per repo rather than global on
87
+ purpose: ctrlrelay works unattended over issue and pull-request text from
88
+ many repositories and opens pull requests, so a weaker model behind the
89
+ whole daemon would produce bad pull requests at machine speed with nobody
90
+ watching. An unroutable session goes to the default backend, never to the
91
+ opt-in one, and repo names match case-insensitively. A value no adapter
92
+ answers to stops the daemon at startup naming the repository, the same way
93
+ an unknown `agent.type` already did (closes #286)
94
+ - The status page and `/api/status` show how this node's runs went over
95
+ the last 7 and 30 days, overall and per repo: runs started, done,
96
+ failed, blocked and still running, success rate, median time to PR and
97
+ to merge, and issues dispatched again after a done run. Cost per run and
98
+ stuck runs are shown as not recorded yet (refs #230)
99
+
100
+ ### Changed
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)
106
+ - The subprocess machinery every CLI-driven adapter shares — the timeout, the
107
+ kill on cancel, the replacing decode, the checkpoint read and #204's outcome
108
+ report on every exit path — moved from `ClaudeDispatcher` to a
109
+ `CliAgentAdapter` base both adapters inherit. A second copy of it is how the
110
+ next backend gets dispatch failures that nothing counts.
111
+ - `ctrlrelay node check` skips its agent-login row, and says why, when no
112
+ repository routes to `claude` — the global `agent.type` is not `claude` and
113
+ no `repos[].agent_type` names it. That check is a `claude -p` call reading
114
+ the claude subscription, so on another backend it reported a failed login
115
+ against a CLI the deployment does not use; with even one repo still on
116
+ `claude` it runs, so an expired login shows up there and not as that repo's
117
+ next failed job.
118
+
119
+ ### Fixed
120
+
121
+ - The code review no longer runs in the agent's worktree. It runs in a
122
+ fresh clone of the commit the relay pushed, in a new temporary
123
+ directory, removed afterwards. Before, a nested repository the agent
124
+ left in its worktree (a gitlink with its own `.git` and config) made a
125
+ plain `git status` or `git diff` there run a program the agent chose,
126
+ in the reviewer's process, outside the agent sandbox. The copy also
127
+ switches off filter and diff drivers from the operator's own git
128
+ config (for example git-lfs or a textconv), so a committed
129
+ `.gitattributes` cannot point agent files at them. If no clean copy can
130
+ be made, the PR is announced as not reviewed (`copy_failed`); it never
131
+ falls back to the worktree (#263)
132
+ - Personalization auto-pull no longer reports success when it could not
133
+ sync. A dirty working tree now fails the run, logged at error level with
134
+ the dirty entries; every run reports `behind N` (a dirty tree still
135
+ fetches to measure it). `push` pre-flights the git identity and refuses
136
+ with `git identity unresolvable` instead of leaving a dirty tree.
137
+ `docs/personalization.md` describes the open question of app-rewritten
138
+ files such as `settings.json` (refs #287)
139
+
140
+ ### Known limits
141
+
142
+ - The `opencode` backend writes no usage reading. A self-hosted model has no
143
+ subscription window, and recording "unknown" after every run would erase a
144
+ reading the claude backend holds and un-pause a machine that is at its limit.
145
+ - `agent.sandbox: "bwrap"` is **refused**, not ignored, alongside an `opencode`
146
+ backend: that confinement binds `~/.claude/*` writable and copies
147
+ `~/.claude.json`, so it is written for one CLI. Running unconfined under a
148
+ config that says otherwise would be worse than a daemon that will not start.
149
+ Credential stripping (#219) applies to every backend either way.
150
+ - Whether a given local model can drive the pipeline is a separate question
151
+ from whether the adapter is correct, and this change does not answer it.
152
+ Measured on an M1 Max on 2026-11-01: `qwen2.5-coder:32b-instruct-q4_K_M`
153
+ emitted **no tool calls at all** (0/3) while advertising `tools` in its
154
+ metadata, and `qwen3-coder:30b` emitted them (3/3). ctrlrelay's agents signal
155
+ DONE / BLOCKED / FAILED by running a shell command that writes the checkpoint
156
+ file, so a model that cannot call tools cannot finish a session. A declared
157
+ capability is not evidence; only a live call is.
158
+
10
159
  ## [0.17.1] - 2026-11-01
11
160
 
12
161
  ### Fixed
@@ -484,7 +633,7 @@ daemon with any of it.
484
633
  rewind even when every other ref applied. `ensure_bare_repo` read that
485
634
  exit code as failure and aborted the session.
486
635
 
487
- Observed on `AInvirion/zzsites`, whose sweep died reporting
636
+ Observed on one of the swept repositories, whose sweep died reporting
488
637
  `WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
489
638
  in the same fetch that had just advanced `main`. Twelve bare repos
490
639
  were holding a `dependabot/*` ref at the time, each one primed to fail
@@ -515,8 +664,7 @@ daemon with any of it.
515
664
  `✅ Scheduled secops sweep done: N/M ok`, which fires once every repo
516
665
  has been swept. On the 2026-09-09 sweep that was hours after the reply
517
666
  and named no repo: four answers merged 20 Dependabot PRs across
518
- `ai-miner`, `kenos-orchestrator`, `prompt-injection-benchmark` and
519
- `aiproxyguard-promptest`, and not one outbound message went back to
667
+ four repositories, and not one outbound message went back to
520
668
  the operator. Answering looked indistinguishable from being ignored.
521
669
 
522
670
  The inline path now reports the same three outcomes the sweeper
@@ -1052,7 +1200,7 @@ during the dogfood validation:
1052
1200
 
1053
1201
  - **`setup` no longer lists the personalization repo under
1054
1202
  `repos:`.** When the operator's personalization repo lives under
1055
- one of the configured owners (e.g. `alice/dotclaude` while `alice`
1203
+ one of the configured owners (e.g. `alice/agent-config` while `alice`
1056
1204
  is also an enumerated owner), it was being added to the dev
1057
1205
  pipeline's monitored set — the poller would have polled it for
1058
1206
  issues and worktree-cloned it. The repo is the cross-machine sync
@@ -1214,7 +1362,7 @@ personalization flow.
1214
1362
  ### Docs
1215
1363
 
1216
1364
  - **[Personalization sync](docs/personalization.md)** (#125). New
1217
- page (nav order 8) covering setup, the dotclaude repo layout, the
1365
+ page (nav order 8) covering setup, the personalization repo layout, the
1218
1366
  init/status/push/pull lifecycle, `--no-adopt`, auto-pull cron,
1219
1367
  multi-machine bootstrap, and the gotchas (worktrees, edit-through-
1220
1368
  symlink semantics, allowlist enforcement, conflict handling, strict
@@ -2109,7 +2257,9 @@ pipeline).
2109
2257
  per-phase implementation plans (Phase 0 through Phase 4).
2110
2258
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2111
2259
 
2112
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...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
2262
+ [0.18.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...v0.18.0
2113
2263
  [0.17.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...v0.17.1
2114
2264
  [0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
2115
2265
  [0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.17.1
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/
@@ -21,16 +21,52 @@ paths:
21
21
  # AInvirion: AINVIRION # GitHub owner -> on-disk folder name
22
22
  # SemClone: SEMCL.ONE
23
23
 
24
- # Headless coding agent. `type` selects the adapter (currently only
25
- # "claude" is implemented; other backends — codex, opencode, hermes,
26
- # kiro — are planned). Legacy `claude:` key is still accepted as an
27
- # alias for backwards compatibility but is deprecated.
24
+ # Headless coding agent. `type` selects the adapter: "claude" and
25
+ # "opencode" are implemented, and codex/hermes/kiro are still planned.
26
+ # An unimplemented value stops the daemon at startup rather than falling
27
+ # back to claude, so a typo here is loud. Legacy `claude:` key is still
28
+ # accepted as an alias for backwards compatibility but is deprecated.
29
+ #
30
+ # `type` is the DEFAULT for every repo. A single repo can opt into
31
+ # another backend with `repos[].agent_type` — see the repos block at the
32
+ # bottom of this file. That is the intended shape: claude everywhere,
33
+ # one low-stakes repo on a self-hosted model, rather than a global flip.
28
34
  agent:
29
35
  type: "claude"
36
+ # claude's own binary and nothing else's. The opencode block below has
37
+ # its own, because most configs set this to the literal "claude" and
38
+ # a second backend reading it would exec the wrong program.
30
39
  binary: "claude"
31
40
  default_timeout_seconds: 1800
32
41
  output_format: "json"
33
42
 
43
+ # Read only when a backend of type "opencode" is built, so this block
44
+ # is inert on a claude-only deployment and harmless to leave in place.
45
+ #
46
+ # `model` is "provider/model" as opencode spells it. Point a provider
47
+ # at a self-hosted OpenAI-compatible endpoint in opencode's own config
48
+ # (~/.config/opencode/opencode.json), e.g.
49
+ #
50
+ # {"provider": {"local": {"npm": "@ai-sdk/openai-compatible",
51
+ # "options": {"baseURL": "http://127.0.0.1:11434/v1"},
52
+ # "models": {"qwen3-coder:30b": {}}}}}
53
+ #
54
+ # and name it here as "local/qwen3-coder:30b". The endpoint, its
55
+ # credential and the model list live there, not in ctrlrelay — leaving
56
+ # `model` unset lets opencode pick, which is usually what you want.
57
+ #
58
+ # One measured caveat before choosing a model: a model can advertise
59
+ # tool support and emit no tool calls at all. ctrlrelay's agents
60
+ # signal DONE / BLOCKED / FAILED by RUNNING A SHELL COMMAND that
61
+ # writes the checkpoint file, so a model that cannot call tools cannot
62
+ # finish a session however good its prose is. Test with a live call.
63
+ # opencode:
64
+ # binary: "opencode"
65
+ # model: "local/qwen3-coder:30b"
66
+ # # Falls back to agent.default_timeout_seconds when unset. A local
67
+ # # model is usually slower per token than a hosted one.
68
+ # default_timeout_seconds: 3600
69
+
34
70
  # EXACTLY ONE transport is active, and it is the one named by `type`.
35
71
  # The other blocks below are inert: they may stay in the file, but nothing
36
72
  # reads them. Running Telegram and Mattermost at the same time is NOT
@@ -184,6 +220,16 @@ repos: []
184
220
  # - name: "owner/repo"
185
221
  # # Optional when paths.repo_root is set; otherwise required.
186
222
  # local_path: "~/Projects/repo"
223
+ # # Which agent backend runs THIS repo. Omit for agent.type, which
224
+ # # is where every repo is unless it says otherwise. A value no
225
+ # # adapter answers to stops the daemon at startup naming this repo.
226
+ # #
227
+ # # Opt in per repo rather than globally. ctrlrelay works unattended
228
+ # # over issue and pull-request text and opens pull requests, so a
229
+ # # weaker model behind the whole daemon produces bad pull requests
230
+ # # at machine speed with nobody watching. Put a low-stakes repo
231
+ # # here; leave the ones that matter on claude.
232
+ # agent_type: "opencode"
187
233
  # automation:
188
234
  # dependabot_patch: auto
189
235
  # dependabot_minor: ask
@@ -208,3 +254,20 @@ repos: []
208
254
  # # generated while blocked in this call, so a slow default here
209
255
  # # just adds idle wall-clock time. Defaults to 600 (10 minutes).
210
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
@@ -70,6 +70,124 @@ paths:
70
70
  | `repo_root` | path | no | Convention root for repo clones. When set, `repos[].local_path` may be omitted and is derived as `${repo_root}/${owner.lower()}/${repo}` (since v0.4.0). Without `repo_root`, every repo entry must declare its own `local_path` (legacy behaviour). |
71
71
  | `owner_aliases` | object | no | **Deprecated since v0.4.0.** Pre-0.4.0 this remapped the on-disk folder name (e.g. `SemClone` -> `SEMCL.ONE`). The path resolver now always uses `owner.lower()`, so aliases are no longer consulted. Parsing is retained so 0.3.x configs still load; a `DeprecationWarning` is emitted when a non-empty mapping is supplied. To silence: drop the `owner_aliases` block, rename your on-disk folder to match `owner.lower()`, or set a per-repo `local_path` override for any that genuinely deviate. |
72
72
 
73
+ ## agent backends
74
+
75
+ `agent.type` picks the adapter ctrlrelay uses to talk to a coding-agent
76
+ CLI. Two are implemented:
77
+
78
+ | `agent.type` | CLI it runs | notes |
79
+ |---|---|---|
80
+ | `claude` (default) | `claude -p --output-format stream-json --verbose --dangerously-skip-permissions` | Reads the subscription usage; supports `sandbox: "bwrap"`. |
81
+ | `opencode` | `opencode run --format json --dangerously-skip-permissions` | Can target a self-hosted OpenAI-compatible endpoint. No usage reading; no bwrap yet. |
82
+
83
+ Anything else raises at startup with a message naming the type. That is
84
+ deliberate: a typo must stop the daemon rather than silently fall back
85
+ to claude.
86
+
87
+ ### Per-repo opt-in
88
+
89
+ `agent.type` is the default for every repo. A single repo opts into
90
+ another backend with `repos[].agent_type`:
91
+
92
+ ```yaml
93
+ agent:
94
+ type: "claude" # everything, unless a repo says otherwise
95
+ opencode:
96
+ model: "local/qwen3-coder:30b"
97
+
98
+ repos:
99
+ - name: "your-org/important-service" # claude
100
+ - name: "your-org/scratchpad"
101
+ agent_type: "opencode" # the local model
102
+ ```
103
+
104
+ Per repo rather than globally, on purpose. ctrlrelay runs unattended
105
+ over issue and pull-request text from many repositories and opens pull
106
+ requests. A weaker model behind the whole daemon produces bad pull
107
+ requests at machine speed with nobody watching, so the shape that is
108
+ safe is claude everywhere plus an explicit opt-in for low-stakes work.
109
+
110
+ Repo names are matched case-insensitively — GitHub treats `owner/repo`
111
+ that way and the config, an issue payload and `gh` output do not
112
+ reliably agree on the case.
113
+
114
+ ### agent.opencode
115
+
116
+ ```yaml
117
+ agent:
118
+ type: "claude"
119
+ opencode:
120
+ binary: "opencode"
121
+ model: "local/qwen3-coder:30b"
122
+ default_timeout_seconds: 3600
123
+ ```
124
+
125
+ | Key | Type | Default | Description |
126
+ |---|---|---|---|
127
+ | `binary` | string | `"opencode"` | The `opencode` executable. The bare name is resolved like every other tool (PATH, then `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`), which matters under systemd where the unit's PATH is minimal. An absolute or relative path is used exactly as written. |
128
+ | `model` | string | `null` | `provider/model` as opencode spells it, passed as `--model`. Unset leaves the choice to opencode's own config, which is usually right. |
129
+ | `default_timeout_seconds` | int | `null` | Falls back to `agent.default_timeout_seconds`. A local model is usually slower per token than a hosted one. |
130
+
131
+ This block is its own rather than a reuse of `agent.binary`: that field
132
+ is claude's, most deployed configs set it to the literal `"claude"`, and
133
+ a second backend reading it would exec `claude` with `opencode`'s
134
+ arguments — a failure that looks like the model's fault.
135
+
136
+ **The endpoint is configured in opencode, not here.** Point a provider
137
+ at your self-hosted OpenAI-compatible server in
138
+ `~/.config/opencode/opencode.json`:
139
+
140
+ ```json
141
+ {
142
+ "provider": {
143
+ "local": {
144
+ "npm": "@ai-sdk/openai-compatible",
145
+ "options": { "baseURL": "http://127.0.0.1:11434/v1" },
146
+ "models": { "qwen3-coder:30b": {} }
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ and name it here as `local/qwen3-coder:30b`. The credential, if the
153
+ endpoint needs one, lives there too.
154
+
155
+ ### What `opencode` does not do yet
156
+
157
+ - **No usage reading.** A self-hosted model has no subscription window.
158
+ The adapter writes nothing to `usage.json` — not even "unknown" —
159
+ because marking it unknown after every run would erase a reading the
160
+ claude backend legitimately holds and un-pause a machine that is
161
+ genuinely at its limit. On a deployment with no claude backend at all,
162
+ `usage` stays empty and the idle probe does not run.
163
+ - **No `sandbox: "bwrap"`.** The confinement binds `~/.claude/*`
164
+ writable and copies `~/.claude.json`: it is written for one CLI.
165
+ Combining `agent.sandbox: "bwrap"` with an `opencode` backend is
166
+ **refused at startup** rather than silently run unconfined. Set
167
+ `agent.sandbox: "none"` to use it. Credential stripping (#219) applies
168
+ to every backend either way.
169
+ - **`ctrlrelay node check` skips the agent-login row** when no
170
+ repository routes to `claude` — the global `agent.type` is not
171
+ `claude` and no `repos[].agent_type` names it — because that check is
172
+ a `claude` call reading the claude subscription. With a global
173
+ `opencode` and even one repo on `claude`, the row still runs. The check
174
+ never probes `opencode` itself: verify it by running its CLI once by
175
+ hand.
176
+
177
+ ### Picking a model for `opencode`
178
+
179
+ One result worth knowing before you choose, measured on an M1 Max on
180
+ 2026-11-01: **a model can advertise tool support and emit no tool calls
181
+ at all.** `qwen2.5-coder:32b-instruct-q4_K_M` scored 0/3 on tool calling
182
+ while declaring `tools` in its metadata and shipping a tool-aware
183
+ template; `qwen3-coder:30b` scored 3/3.
184
+
185
+ That matters more here than it looks. ctrlrelay's agents signal `DONE`,
186
+ `BLOCKED_NEEDS_INPUT` and `FAILED` by **running a shell command that
187
+ writes the checkpoint file** — so a model that cannot call tools cannot
188
+ finish a session at all, however good its prose is. Test with a live
189
+ call; a declared capability is not evidence.
190
+
73
191
  ## claude
74
192
 
75
193
  Controls how ctrlrelay invokes the `claude` CLI.
@@ -413,7 +531,7 @@ web:
413
531
  |---|---|---|---|
414
532
  | `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
415
533
  | `port` | int (1-65535) | `8787` | Port to listen on. |
416
- | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
534
+ | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. Events older than this are deleted, so the run numbers' time to PR and time to merge are shown only for a window whose runs all started after the oldest event kept. Set it to `30` to get the 30-day ones; they appear once 30 days of events have been kept. |
417
535
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
418
536
  | `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
419
537
  | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
@@ -537,8 +655,11 @@ repos:
537
655
  | `name` | string | **yes** | — | GitHub `owner/repo` slug. Used for `gh` calls and bare-repo / worktree naming. |
538
656
  | `local_path` | path | conditional | derived | Where the repo is checked out on disk for human use. Optional when `paths.repo_root` is set (then derived as `${repo_root}/${owner.lower()}/${repo}`, since v0.4.0); required otherwise. An explicit value always wins as override. ctrlrelay itself uses bare mirrors under `paths.bare_repos`. |
539
657
  | `dev_branch_template` | string | no | `"fix/issue-{n}"` | Branch-name template for dev-pipeline runs. `{n}` is replaced by the issue number. |
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. |
540
659
  | `automation` | object | no | (defaults) | See [automation](#repos-automation). |
541
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). |
542
663
  | `deploy` | object | no | `null` | Reserved for deploy policy. Currently surfaced in `ctrlrelay config repos` but otherwise inert. |
543
664
 
544
665
  ### repos[].automation
@@ -737,6 +858,109 @@ repos:
737
858
  - This does not change the overall session timeout (`agent.default_timeout_seconds`,
738
859
  default 1800s) — it only bounds the one `ci wait` call.
739
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
+
740
964
  ## schedules
741
965
 
742
966
  In-process cron jobs run by the poller daemon. All expressions are
@@ -774,6 +998,10 @@ personalization:
774
998
  target: "~/.claude/CLAUDE.md"
775
999
  - source: "global/skills/"
776
1000
  target: "~/.claude/skills/"
1001
+ - source: "global/settings.json"
1002
+ target: "~/.claude/settings.json"
1003
+ mode: merge
1004
+ merge_keys: [model, outputStyle, hooks]
777
1005
  - source: "claude-memory/${PROJECT}/"
778
1006
  target: "~/.claude/projects/${PROJECT_ENCODED}/memory/"
779
1007
  project_scoped: true
@@ -802,6 +1030,8 @@ agree.
802
1030
  | `source` | string | **yes** | Path inside the personalization repo. Trailing slash means directory. |
803
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}`. |
804
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`. |
805
1035
 
806
1036
  The `${PROJECT}` slug uses **`<owner>--<repo>`** with a double
807
1037
  hyphen so `a-b/c` and `a/b-c` produce different slugs. `${PROJECT_ENCODED}`
@@ -331,6 +331,23 @@ Added in #249:
331
331
  | `node.open_questions` | How many questions are waiting for an answer. |
332
332
  | `fleet` | `{peers: [...]}` with one item per `fleet.peers` URL: `url`, `name`, `reachable`, `checked_at`, `error`, `state`, `node` (that peer's `node` block), `last_known` (its last block while it does not answer) and `needs_you`. `null` when no peers are configured. |
333
333
 
334
+ Added in #230: `numbers` is how this machine's runs went, for the last 7 and
335
+ the last 30 days (`numbers.windows`, each with `days`, `since`/`since_ts`,
336
+ `until`/`until_ts`). The page shows it in the **Numbers** box, with a table
337
+ per repo under it. Numbers across machines are not here; they belong to the
338
+ control plane. Every field is defined in `src/ctrlrelay/web/numbers.py`.
339
+
340
+ | Field (in `overall` and in each `by_repo` item) | What |
341
+ |---|---|
342
+ | `started` | Runs (rows in the sessions table) started in the window, both ends included. A blocked run resumed after an answer is still one run. |
343
+ | `done`, `failed`, `blocked`, `running`, `other` | How many of those runs have that status now. `running` includes a run left behind by a daemon that stopped mid-run. The five add up to `started`. |
344
+ | `success_rate` | `done / (done + failed + blocked)`, 0 to 1; `null` when none has finished. |
345
+ | `time_to_pr_s`, `time_to_merge_s` | `{median, n}`: seconds from the run's start to its first `pr_opened` (or `merged`) event, over the `n` runs that have one. Events stamped after now are not read. `null` when events cannot be read, or when a run in the window started before the oldest event still kept: its PR event may have been deleted. The daemon deletes events older than `web.history_days` (7 by default), so with the default the 30-day times are `null`. **To get them, set `web.history_days` to 30**; they appear once the events kept reach back past every run in the window (up to 30 days after the change, since deleted events do not come back). |
346
+ | `came_back` | Distinct issues dispatched again in the window after an earlier run on the same issue had ended `done`. The window's `came_back_issues` lists them (`repo`, `issue_number`, `url`; at most 50). An issue reopened on GitHub and not picked up again is not counted: ctrlrelay does not record reopening. |
347
+ | `cost_per_run`, `stuck` | Always `null` for now: cost per run is not recorded yet (#223), and no run records that it was stuck (#224). The window's `unavailable` says this, and why any other field is `null`. |
348
+
349
+ `numbers` is `null`, with a line in `errors`, when the sessions cannot be read.
350
+
334
351
  **The page with peers.** A **Machines** strip shows one card per machine,
335
352
  this one first: state, slots, 5-hour and 7-day usage, heartbeat age. A card is
336
353
  red when the machine is paused, silent, unknown, not reachable, or not read