ctrlrelay 0.21.0__tar.gz → 0.22.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 (217) hide show
  1. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/CHANGELOG.md +44 -1
  2. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/configuration.md +23 -1
  4. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/pyproject.toml +1 -1
  5. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/cli.py +11 -0
  6. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatcher.py +30 -0
  7. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/events.py +3 -0
  8. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github.py +25 -0
  9. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/publish.py +8 -0
  10. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/worktree.py +34 -1
  11. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/dev.py +537 -92
  12. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/static/index.html +10 -1
  13. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/status.py +155 -0
  14. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/_publish_fakes.py +10 -1
  15. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_no_keys.py +4 -2
  16. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_event_emission.py +9 -2
  17. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_events.py +1 -1
  18. ctrlrelay-0.22.0/tests/test_no_change_run.py +219 -0
  19. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_relay_git_263.py +1 -1
  20. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_review_copy.py +42 -0
  21. ctrlrelay-0.22.0/tests/test_review_gate.py +493 -0
  22. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_status.py +136 -1
  23. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/dependabot.yml +0 -0
  27. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/build.yml +0 -0
  28. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/cla.yml +0 -0
  29. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/pages.yml +0 -0
  30. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/publish.yml +0 -0
  31. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.github/workflows/test.yml +0 -0
  32. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/.gitignore +0 -0
  33. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/CODE_OF_CONDUCT.md +0 -0
  34. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/CONTRIBUTING.md +0 -0
  35. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/LICENSE +0 -0
  36. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/README.md +0 -0
  37. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/SECURITY.md +0 -0
  38. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/config/orchestrator.yaml.example +0 -0
  39. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/Gemfile +0 -0
  40. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/_config.yml +0 -0
  41. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/adding-a-machine.md +0 -0
  42. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/architecture.md +0 -0
  43. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/bridge.md +0 -0
  44. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/cli.md +0 -0
  45. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/development.md +0 -0
  46. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/feedback-loop.md +0 -0
  47. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/getting-started.md +0 -0
  48. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/index.md +0 -0
  49. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/llms.txt +0 -0
  50. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/operations.md +0 -0
  51. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/personalization.md +0 -0
  52. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/registered-mode.md +0 -0
  53. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  54. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  55. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/__init__.py +0 -0
  56. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  57. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  58. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/factory.py +0 -0
  59. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/handler.py +0 -0
  60. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  61. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  62. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  63. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/server.py +0 -0
  64. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  65. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/__init__.py +0 -0
  66. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/agent_env.py +0 -0
  67. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/archived.py +0 -0
  68. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/audit.py +0 -0
  69. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/binaries.py +0 -0
  70. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  71. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/claims.py +0 -0
  72. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/code_review.py +0 -0
  73. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/config.py +0 -0
  74. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  75. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/github_budget.py +0 -0
  76. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  77. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/issue_questions.py +0 -0
  78. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/job_claims.py +0 -0
  79. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/network.py +0 -0
  80. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/node_check.py +0 -0
  81. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/obs.py +0 -0
  82. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/orphans.py +0 -0
  83. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/poller.py +0 -0
  84. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  85. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  86. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  87. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/redact.py +0 -0
  88. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/scheduler.py +0 -0
  89. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/state.py +0 -0
  90. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/stuck.py +0 -0
  91. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/core/usage.py +0 -0
  92. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  93. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/dashboard/client.py +0 -0
  94. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/gh_protocol.py +0 -0
  95. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/install.py +0 -0
  96. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  97. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/manager.py +0 -0
  98. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/merge.py +0 -0
  99. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/personalization/paths.py +0 -0
  100. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  101. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/base.py +0 -0
  102. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  103. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  104. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/pipelines/task.py +0 -0
  105. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/setup.py +0 -0
  106. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/__init__.py +0 -0
  107. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  108. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  109. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  110. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  111. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  112. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  113. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  114. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  115. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/__init__.py +0 -0
  116. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/base.py +0 -0
  117. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  118. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  119. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/__init__.py +0 -0
  120. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/fleet.py +0 -0
  121. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/health.py +0 -0
  122. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/links.py +0 -0
  123. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/numbers.py +0 -0
  124. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/server.py +0 -0
  125. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/transcript.py +0 -0
  126. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/src/ctrlrelay/web/waiting.py +0 -0
  127. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/__init__.py +0 -0
  128. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/_poller_fakes.py +0 -0
  129. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/conftest.py +0 -0
  130. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/gh_search_issues.json +0 -0
  131. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  132. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  133. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_backend_wiring.py +0 -0
  134. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_routing.py +0 -0
  135. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_agent_sandbox.py +0 -0
  136. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_archived.py +0 -0
  137. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_audit.py +0 -0
  138. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_binary_resolution.py +0 -0
  139. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_protocol.py +0 -0
  140. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_server.py +0 -0
  141. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_bridge_start_command.py +0 -0
  142. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_chat_machines.py +0 -0
  143. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_checkpoint.py +0 -0
  144. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_claim_reconciliation.py +0 -0
  145. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_ci_wait.py +0 -0
  146. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_dev.py +0 -0
  147. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_repos.py +0 -0
  148. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_secops.py +0 -0
  149. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_start.py +0 -0
  150. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_cli_version.py +0 -0
  151. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_code_review.py +0 -0
  152. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_config.py +0 -0
  153. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dashboard_client.py +0 -0
  154. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_integration.py +0 -0
  155. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_pipeline.py +0 -0
  156. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_plan_step.py +0 -0
  157. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_step_switches.py +0 -0
  158. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_stuck.py +0 -0
  159. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dev_work_steps.py +0 -0
  160. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatch_health.py +0 -0
  161. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatch_outcome_hook.py +0 -0
  162. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_dispatcher.py +0 -0
  163. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_docs_site.py +0 -0
  164. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_fleet.py +0 -0
  165. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_gh_protocol.py +0 -0
  166. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_github.py +0 -0
  167. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_github_budget.py +0 -0
  168. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_install.py +0 -0
  169. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_issue_outcome_announcement.py +0 -0
  170. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_issue_questions.py +0 -0
  171. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_job_claims.py +0 -0
  172. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_mattermost_handler.py +0 -0
  173. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_network.py +0 -0
  174. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_node_check.py +0 -0
  175. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_node_claims.py +0 -0
  176. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_notify_transport.py +0 -0
  177. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_obs.py +0 -0
  178. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_opencode_adapter.py +0 -0
  179. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_orphans.py +0 -0
  180. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_personalization.py +0 -0
  181. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_personalization_merge.py +0 -0
  182. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pipeline_base.py +0 -0
  183. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller.py +0 -0
  184. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller_recheck.py +0 -0
  185. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_poller_search.py +0 -0
  186. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_post_merge.py +0 -0
  187. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pr_verifier.py +0 -0
  188. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pr_watcher.py +0 -0
  189. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_pre_dispatch_retry.py +0 -0
  190. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_publish_failure_retry.py +0 -0
  191. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_backoff.py +0 -0
  192. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_expiry.py +0 -0
  193. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_question_survives_restart.py +0 -0
  194. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_redact.py +0 -0
  195. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_relay_alert_off_lock.py +0 -0
  196. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_resume_prompt_contract.py +0 -0
  197. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_scheduler.py +0 -0
  198. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_secops_integration.py +0 -0
  199. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_secops_pipeline.py +0 -0
  200. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_setup.py +0 -0
  201. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_state.py +0 -0
  202. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_stuck.py +0 -0
  203. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_task_pipeline.py +0 -0
  204. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_telegram_handler.py +0 -0
  205. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_timeout_save.py +0 -0
  206. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_transport.py +0 -0
  207. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_transport_resolution.py +0 -0
  208. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  209. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_usage.py +0 -0
  210. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_health.py +0 -0
  211. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_links.py +0 -0
  212. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_numbers.py +0 -0
  213. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_peer_links.py +0 -0
  214. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_server.py +0 -0
  215. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_transcript.py +0 -0
  216. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_web_waiting.py +0 -0
  217. {ctrlrelay-0.21.0 → ctrlrelay-0.22.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - The code review is tied to the commit it read. Each `review_finished`
15
+ event records that commit (`head_sha`) and whether the findings reached
16
+ the PR (`posted`), so the status page feed and `/api/status` show what a
17
+ verdict is about. After a review the relay reads the PR's head from
18
+ GitHub; if somebody pushed after the review, the PR gets a comment saying
19
+ it needs review again, a `review_stale` event is recorded, and the new
20
+ head is reviewed before the run reports done. With
21
+ `steps.review: required`, the PR is opened as a draft (a reused PR is put
22
+ back to draft when the run pushes to it) and marked ready only once the
23
+ latest review posted on its current head. The new head is fetched from
24
+ the PR's own ref, since the run's branch is checked out in its worktree.
25
+ A repository that does not allow drafts (a private one on GitHub's free
26
+ plan) gets a normal PR with a comment saying it has not been reviewed
27
+ yet. A reused PR that cannot be put back to draft gets the same comment.
28
+
29
+ ### Fixed
30
+
31
+ - The status page no longer says "looks stuck" while the agent is inside
32
+ one long command, such as a 20-minute test run. A tool call writes
33
+ nothing to the transcript until it returns. So before calling a card
34
+ stuck, the page reads `ps` for a process under that run's own agent
35
+ (the dispatcher records its pid in `agents/<session>.pid` beside the
36
+ state database) that started at or after the agent's last step. If it
37
+ finds one, it shows "running `<program>`" instead, with the program's
38
+ name only. An idle agent is still stuck. Where the process
39
+ table cannot be read, the card stays stuck and says why (#309).
40
+
41
+ - A run that correctly changes nothing is no longer recorded as a failed
42
+ publish. The agent can now finish with `"no_changes": True` and a one-line
43
+ `"no_changes_reason"`. When the relay's own check also finds no new
44
+ commits, it opens no pull request, ends the session `done`, says so on
45
+ the issue, and tells chat "finished, no change needed". A DONE with no
46
+ commits and no declaration still fails as a forgotten commit. A
47
+ declaration on a branch that has commits is ignored, and the commits are
48
+ published. So is a declaration from a run that has already pushed or has
49
+ an open PR, such as a fix round or a resumed session, since ending it
50
+ done would orphan that PR (#336).
51
+
10
52
  ## [0.21.0] - 2026-11-05
11
53
 
12
54
  ### Added
@@ -2407,7 +2449,8 @@ pipeline).
2407
2449
  per-phase implementation plans (Phase 0 through Phase 4).
2408
2450
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2409
2451
 
2410
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...HEAD
2452
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.22.0...HEAD
2453
+ [0.22.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.21.0...v0.22.0
2411
2454
  [0.21.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.2...v0.21.0
2412
2455
  [0.20.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.1...v0.20.2
2413
2456
  [0.20.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.20.0...v0.20.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.21.0
3
+ Version: 0.22.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/
@@ -539,7 +539,7 @@ web:
539
539
  | `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. |
540
540
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
541
541
  | `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. |
542
- | `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. |
542
+ | `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, or the agent is still inside a command it started at its last step (read from `ps`; the card then says "running `<program>`"). Where the process table cannot be read, the card stays "looks stuck" and says why. |
543
543
  | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
544
544
 
545
545
  The page has no login and shows repository names, issue titles and the
@@ -946,6 +946,28 @@ an exception inside the review are all failures. It is about the review
946
946
  having happened, not about what it found. **`review: off`** is the same as
947
947
  `code_review.method: off`, and loads as that.
948
948
 
949
+ A review is about one commit. With `review: required` the PR is opened as a
950
+ **draft**, and a PR the run reuses is put back to draft when the run pushes to
951
+ it, because its new commits have not been reviewed. The relay marks it ready
952
+ for review only when the latest review posted on the PR's current head. If
953
+ the PR stays a draft, nothing was handed over. Draft pull requests need a
954
+ public repository or a paid GitHub plan; where GitHub refuses a draft, the
955
+ publish step fails and says why.
956
+
957
+ With the review `on`, unset or `required`, the relay reads the PR's head from
958
+ GitHub after each review. If somebody pushed after the review, the verdict is
959
+ stale: the PR gets a comment saying it needs review again, a `review_stale`
960
+ event is recorded, and the new head is reviewed before the run reports done
961
+ (at most three reviews in all; a head still moving after that is not a
962
+ pass). With `required`, a head GitHub will not report is not a pass either.
963
+
964
+ Each `review_finished` event names the commit it reviewed, so the status page
965
+ feed and `/api/status` show what the verdict is about:
966
+ `{"pr_number", "ok", "head_sha", "status", "posted"}`. `ok` means the reviewer
967
+ read the diff; `posted` means its findings reached the PR, which is what
968
+ `required` checks. `review_stale` carries `{"pr_number", "head_sha",
969
+ "current_sha"}`.
970
+
949
971
  Two settings for one reviewer must agree, or the config refuses to load:
950
972
 
951
973
  - `code_review.method: off` with `steps.review: on` or `required`;
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.21.0"
3
+ version = "0.22.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"
@@ -566,6 +566,17 @@ async def announce_dev_success(
566
566
  the caller's tests assert on.
567
567
  """
568
568
  sent: list[str] = []
569
+ outputs = getattr(result, "outputs", None) or {}
570
+ if outputs.get("no_change") is True:
571
+ # No pull request by design (#336): nothing to warn about, and
572
+ # "PR ready" with an empty link would be false.
573
+ line = (
574
+ f"✅ {repo}: finished, no change needed: "
575
+ f"{outputs.get('no_change_reason') or 'no reason given'}"
576
+ )
577
+ await transport.send(line)
578
+ sent.append(line)
579
+ return sent
569
580
  warning = unreviewed_pr_warning(repo, result)
570
581
  if warning is not None:
571
582
  await transport.send(warning)
@@ -265,6 +265,34 @@ class CliAgentAdapter:
265
265
  """
266
266
  return None
267
267
 
268
+ def _record_agent_pid(self, session_id: str, pid: int | None) -> None:
269
+ """Note which process is this session's agent (#309), in
270
+ ``agents/<session_id>.pid`` beside the state database, so the status
271
+ page can tell an agent inside a long command from one that stopped.
272
+ Several runs share the poller's pid, so only the agent's own pid
273
+ says which command belongs to which run. Best-effort; files older
274
+ than a week are pruned here."""
275
+ store = getattr(self, "usage_store", None)
276
+ if store is None or not pid:
277
+ return
278
+ folder = Path(store.path).parent / "agents"
279
+ try:
280
+ folder.mkdir(parents=True, exist_ok=True)
281
+ cutoff = time.time() - 7 * 86400
282
+ for old in folder.glob("*.pid"):
283
+ try:
284
+ if old.stat().st_mtime < cutoff:
285
+ old.unlink()
286
+ except OSError:
287
+ pass
288
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", session_id)
289
+ (folder / f"{safe}.pid").write_text(f"{int(pid)}\n")
290
+ except Exception as e: # noqa: BLE001 - must never fail a run
291
+ log_event(
292
+ _logger, "dispatcher.agent_pid.write_failed",
293
+ session_id=session_id, error=str(e)[:200],
294
+ )
295
+
268
296
  def _record_usage(self, stdout_text: str, ident: dict) -> None:
269
297
  """Keep this run's usage reading, if the backend reports one.
270
298
 
@@ -393,6 +421,8 @@ class CliAgentAdapter:
393
421
  )
394
422
  raise
395
423
 
424
+ self._record_agent_pid(session_id, getattr(proc, "pid", None))
425
+
396
426
  try:
397
427
  stdout, stderr = await asyncio.wait_for(
398
428
  self._communicate_watched(
@@ -42,6 +42,9 @@ class EventKind(StrEnum):
42
42
  CI_FINISHED = "ci_finished"
43
43
  REVIEW_STARTED = "review_started"
44
44
  REVIEW_FINISHED = "review_finished"
45
+ # A review's commit is no longer the PR's head: somebody pushed after
46
+ # it, and the PR needs review again (#222).
47
+ REVIEW_STALE = "review_stale"
45
48
  MESSAGE_SENT = "message_sent"
46
49
  QUESTION_POSTED = "question_posted"
47
50
  QUESTION_HELD = "question_held"
@@ -581,9 +581,14 @@ class GitHubCLI:
581
581
  body: str,
582
582
  head: str,
583
583
  base: str,
584
+ draft: bool = False,
584
585
  ) -> dict[str, Any]:
585
586
  """Create a pull request; return ``{"number", "url"}``.
586
587
 
588
+ ``draft`` opens it as a draft (#222): a repository whose review is
589
+ required gets a PR that reads "not ready" until a review passed
590
+ on its current head.
591
+
587
592
  ``gh pr create`` has no ``--json`` flag (it prints the new
588
593
  pull request's URL), so the number is read from that URL. The
589
594
  old version passed ``--json`` and could never have succeeded;
@@ -599,6 +604,7 @@ class GitHubCLI:
599
604
  "--body", body,
600
605
  "--head", head,
601
606
  "--base", base,
607
+ *(("--draft",) if draft else ()),
602
608
  )
603
609
  url = ""
604
610
  for line in output.splitlines():
@@ -612,6 +618,25 @@ class GitHubCLI:
612
618
  )
613
619
  return {"number": int(match.group(1)), "url": url}
614
620
 
621
+ async def get_pr_head(self, repo: str, pr_number: int) -> dict[str, Any]:
622
+ """``{"sha", "draft"}`` for a pull request: the commit GitHub has
623
+ as its head, and whether it is a draft (#222)."""
624
+ output = await self._run_gh(
625
+ "pr", "view", str(pr_number),
626
+ "--repo", repo,
627
+ "--json", "headRefOid,isDraft",
628
+ )
629
+ data = json.loads(output)
630
+ return {"sha": data.get("headRefOid"), "draft": data.get("isDraft")}
631
+
632
+ async def set_pr_draft(self, repo: str, pr_number: int, draft: bool) -> None:
633
+ """Mark a pull request as a draft, or as ready for review (#222)."""
634
+ await self._run_gh(
635
+ "pr", "ready", str(pr_number),
636
+ "--repo", repo,
637
+ *(("--undo",) if draft else ()),
638
+ )
639
+
615
640
  async def get_default_branch(self, repo: str) -> str:
616
641
  """The repository's default branch, as GitHub reports it.
617
642
 
@@ -45,6 +45,14 @@ class PushRefusedError(Exception):
45
45
  """A push the relay will not make. The message names why."""
46
46
 
47
47
 
48
+ class NothingToPushError(PushRefusedError):
49
+ """The run branch has no commit the default branch lacks (#336).
50
+
51
+ A refusal like any other, unless the agent declared that no change
52
+ was the right outcome: then the run finished, with nothing to push.
53
+ """
54
+
55
+
48
56
  def check_push_target(
49
57
  target: str,
50
58
  *,
@@ -1626,7 +1626,9 @@ class WorktreeManager:
1626
1626
  session_id=session_id,
1627
1627
  branch=branch,
1628
1628
  )
1629
- raise PushRefusedError(
1629
+ from ctrlrelay.core.publish import NothingToPushError
1630
+
1631
+ raise NothingToPushError(
1630
1632
  f"push refused: {branch!r} has no commits that "
1631
1633
  f"{default_branch!r} does not already have; the agent "
1632
1634
  f"must commit its work on {branch!r}"
@@ -1847,6 +1849,37 @@ class WorktreeManager:
1847
1849
 
1848
1850
  return bare_path
1849
1851
 
1852
+ async def fetch_pr_head(self, repo: str, pr_number: int) -> str:
1853
+ """Fetch a pull request's current head into the bare clone and
1854
+ return its commit id (#222).
1855
+
1856
+ Into ``refs/ctrlrelay/review/pr-<n>``, never into the branch: a
1857
+ run's branch is checked out in its worktree for the whole session,
1858
+ and :meth:`ensure_bare_repo` leaves such branches out of its fetch
1859
+ (#253), so a commit pushed by someone else never reached the clone
1860
+ and the review copy of it failed.
1861
+ """
1862
+ if not isinstance(pr_number, int) or pr_number <= 0:
1863
+ raise WorktreeError(f"not a pull request number: {pr_number!r}")
1864
+ bare_path = self._get_bare_repo_path(repo)
1865
+ ref = f"refs/ctrlrelay/review/pr-{pr_number}"
1866
+ await self._run_git(
1867
+ "fetch", "--no-tags", "origin",
1868
+ f"+refs/pull/{pr_number}/head:{ref}",
1869
+ cwd=bare_path,
1870
+ timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
1871
+ )
1872
+ sha = (
1873
+ await self._run_git(
1874
+ "rev-parse", "--verify", "--quiet", "--end-of-options",
1875
+ f"{ref}^{{commit}}",
1876
+ cwd=bare_path,
1877
+ )
1878
+ ).strip()
1879
+ if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
1880
+ raise WorktreeError(f"{ref} is not a commit: {sha[:80]!r}")
1881
+ return sha
1882
+
1850
1883
  async def _follow_origin_default(
1851
1884
  self, repo: str, bare_path: Path, default_branch: str,
1852
1885
  ) -> None: