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.
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CHANGELOG.md +67 -1
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/PKG-INFO +1 -1
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/config/orchestrator.yaml.example +17 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/configuration.md +111 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/personalization.md +142 -10
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/pyproject.toml +1 -1
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/cli.py +47 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/code_review.py +16 -2
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/config.py +190 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/worktree.py +280 -34
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/manager.py +470 -6
- ctrlrelay-0.19.0/src/ctrlrelay/personalization/merge.py +533 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/dev.py +625 -261
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/_publish_fakes.py +2 -0
- ctrlrelay-0.19.0/tests/test_dev_step_switches.py +1004 -0
- ctrlrelay-0.19.0/tests/test_dev_work_steps.py +214 -0
- ctrlrelay-0.19.0/tests/test_personalization_merge.py +1608 -0
- ctrlrelay-0.19.0/tests/test_relay_git_263.py +621 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_review_copy.py +24 -9
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/.gitignore +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/LICENSE +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/README.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/SECURITY.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/cli.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/development.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/index.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/operations.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/registered-mode.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/numbers.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/_poller_fakes.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_backend_wiring.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_routing.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_archived.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_opencode_adapter.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_poller_search.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_numbers.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_server.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.18.0 → ctrlrelay-0.19.0}/tests/test_web_waiting.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
|
@@ -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,
|
|
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
|
|