ctrlrelay 0.5.0__tar.gz → 0.8.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 (114) hide show
  1. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/build.yml +1 -1
  2. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/pages.yml +1 -1
  3. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/publish.yml +1 -1
  4. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/test.yml +1 -1
  5. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CHANGELOG.md +108 -1
  6. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/PKG-INFO +2 -2
  7. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/config/orchestrator.yaml.example +13 -0
  8. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/configuration.md +63 -0
  9. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/pyproject.toml +1 -1
  10. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/cli.py +19 -5
  11. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/config.py +17 -0
  12. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/poller.py +74 -2
  13. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/state.py +74 -0
  14. ctrlrelay-0.8.0/src/ctrlrelay/gh_protocol.py +47 -0
  15. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/install.py +11 -0
  16. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/manager.py +8 -1
  17. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/dev.py +4 -1
  18. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/secops.py +218 -2
  19. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/setup.py +11 -7
  20. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_config.py +160 -0
  21. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dev_pipeline.py +59 -0
  22. ctrlrelay-0.8.0/tests/test_gh_protocol.py +65 -0
  23. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_install.py +39 -0
  24. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_personalization.py +27 -0
  25. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_poller.py +150 -0
  26. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_secops_pipeline.py +485 -0
  27. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_setup.py +140 -6
  28. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_state.py +129 -0
  29. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_transport.py +4 -0
  30. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  31. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  33. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/dependabot.yml +0 -0
  34. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/cla.yml +0 -0
  35. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.gitignore +0 -0
  36. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  37. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CONTRIBUTING.md +0 -0
  38. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/LICENSE +0 -0
  39. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/README.md +0 -0
  40. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/SECURITY.md +0 -0
  41. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/Gemfile +0 -0
  42. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/_config.yml +0 -0
  43. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/architecture.md +0 -0
  44. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/bridge.md +0 -0
  45. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/cli.md +0 -0
  46. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/development.md +0 -0
  47. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/feedback-loop.md +0 -0
  48. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/getting-started.md +0 -0
  49. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/index.md +0 -0
  50. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/operations.md +0 -0
  51. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/personalization.md +0 -0
  52. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/__init__.py +0 -0
  53. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  54. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  55. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  56. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/server.py +0 -0
  57. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  58. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/__init__.py +0 -0
  59. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/audit.py +0 -0
  60. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  61. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  62. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/github.py +0 -0
  63. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/obs.py +0 -0
  64. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  65. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  66. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/scheduler.py +0 -0
  67. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/worktree.py +0 -0
  68. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  69. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/client.py +0 -0
  70. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  71. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/paths.py +0 -0
  72. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  73. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/base.py +0 -0
  74. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  75. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/task.py +0 -0
  76. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/__init__.py +0 -0
  77. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  78. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  79. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  80. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  81. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  82. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  83. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  84. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/__init__.py +0 -0
  85. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/base.py +0 -0
  86. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  87. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  88. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/__init__.py +0 -0
  89. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/conftest.py +0 -0
  90. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_audit.py +0 -0
  91. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_bridge_protocol.py +0 -0
  92. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_bridge_server.py +0 -0
  93. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_checkpoint.py +0 -0
  94. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_ci_wait.py +0 -0
  95. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_dev.py +0 -0
  96. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_repos.py +0 -0
  97. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_secops.py +0 -0
  98. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_start.py +0 -0
  99. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_version.py +0 -0
  100. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dashboard_client.py +0 -0
  101. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dev_integration.py +0 -0
  102. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dispatcher.py +0 -0
  103. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_docs_site.py +0 -0
  104. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_github.py +0 -0
  105. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_obs.py +0 -0
  106. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pipeline_base.py +0 -0
  107. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_post_merge.py +0 -0
  108. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pr_verifier.py +0 -0
  109. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pr_watcher.py +0 -0
  110. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_scheduler.py +0 -0
  111. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_secops_integration.py +0 -0
  112. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_task_pipeline.py +0 -0
  113. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_telegram_handler.py +0 -0
  114. {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_worktree.py +0 -0
@@ -18,7 +18,7 @@ jobs:
18
18
 
19
19
  steps:
20
20
  - name: Checkout
21
- uses: actions/checkout@v6
21
+ uses: actions/checkout@v7
22
22
 
23
23
  - name: Install uv
24
24
  uses: astral-sh/setup-uv@v7
@@ -25,7 +25,7 @@ jobs:
25
25
  working-directory: docs
26
26
  steps:
27
27
  - name: Checkout
28
- uses: actions/checkout@v6
28
+ uses: actions/checkout@v7
29
29
 
30
30
  - name: Setup Ruby
31
31
  uses: ruby/setup-ruby@v1
@@ -29,7 +29,7 @@ jobs:
29
29
  runs-on: ubuntu-latest
30
30
  steps:
31
31
  - name: Checkout
32
- uses: actions/checkout@v6
32
+ uses: actions/checkout@v7
33
33
 
34
34
  - name: Install uv
35
35
  uses: astral-sh/setup-uv@v7
@@ -15,7 +15,7 @@ jobs:
15
15
 
16
16
  steps:
17
17
  - name: Checkout
18
- uses: actions/checkout@v6
18
+ uses: actions/checkout@v7
19
19
 
20
20
  - name: Install uv
21
21
  uses: astral-sh/setup-uv@v7
@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - **`ci_wait_timeout_seconds`: per-repo cap on the dev pipeline's
15
+ `ctrlrelay ci wait` call.** The dev pipeline prompt tells Claude to run
16
+ `ctrlrelay ci wait --pr <PR> --repo <repo> --timeout 600` before
17
+ signaling DONE — a single blocking call, hardcoded to 10 minutes, that
18
+ generates zero tokens while it polls GitHub. Measured directly on a real
19
+ session: two `ci wait` calls accounted for 12 of a 26-minute run, versus
20
+ under 35K output tokens generated the whole time — the wall-clock cost
21
+ is CI runtime, not model work. `ci_wait_timeout_seconds` (default `600`,
22
+ unchanged behavior) lets a repo with a faster CI suite cap that single
23
+ call lower instead of always budgeting the full 10 minutes.
24
+
25
+ ## [0.7.0] - 2026-09-04
26
+
27
+ ### Added
28
+
29
+ - **`require_labels`: an AND-gate between assignment and a label,
30
+ closing the gap `include_labels` (#80) couldn't cover.** `exclude_labels`
31
+ is a block-list and `include_labels` is an OR-alternate trigger, but
32
+ neither could express "only pick this up if it's assigned to me AND
33
+ carries a specific label." On a repo with a long history of
34
+ self-assigned issues used as personal reminders, that meant the poller
35
+ replayed the whole backlog as new work the first time it saw the repo —
36
+ including issues that weren't code tasks at all. `require_labels`
37
+ (default `[]`, no behavior change) tightens the plain-assignment path
38
+ only: when set, an issue needs both the assignment and a matching label.
39
+ `include_labels` keeps triggering on a label match alone regardless.
40
+ An assigned issue missing the required label is left unmarked so a
41
+ later label addition still surfaces it. See #139.
42
+
43
+ ### Fixed
44
+
45
+ - **`ctrlrelay setup --install-daemons` on a fresh SSH-only machine:
46
+ personalization clone ignored the configured git protocol, and
47
+ generated daemon units crash-looped.** The personalization repo
48
+ clone (setup pre-scan and `PersonalizationManager.init`) hardcoded
49
+ an HTTPS URL regardless of `gh`'s configured `git_protocol`,
50
+ so SSH-only operators hit `could not read Username` with the
51
+ failure buried in a single warning line while setup still exited
52
+ 0. `ctrlrelay.gh_protocol` now reads `gh`'s configured protocol and
53
+ builds the matching clone URL; a personalization failure flips
54
+ `SetupResult.personalization_failed` so the CLI prints an ACTION
55
+ NEEDED banner and exits non-zero instead of silently continuing.
56
+ Separately, generated launchd/systemd units point
57
+ `StandardOutput`/`StandardError` at `~/.ctrlrelay/logs/`, which
58
+ setup never created — systemd refuses to start a unit with a
59
+ missing log dir (`EXIT_STDOUT`, 209), and with `Restart=always`
60
+ that's an immediate crash-loop. `write_units` now creates the log
61
+ dir up front and chmods unit files `0600` since they embed
62
+ `CTRLRELAY_TELEGRAM_TOKEN` in plaintext. See #137.
63
+
64
+ ## [0.6.0] - 2026-06-10
65
+
66
+ ### Added
67
+
68
+ - **Secops sweeps now remember operator decisions across days.** The
69
+ `automation_decisions` table had a schema but no producer or consumer
70
+ — every 6am sweep re-asked about the same Dependabot PRs the operator
71
+ had already answered, drowning Telegram. Wired both halves:
72
+ 1. When the BLOCKED loop in `run_secops_all` (or the out-of-band
73
+ `resume_secops_from_pending` sweeper) receives an answer, regex
74
+ extracts every PR# from the question and records one row per PR#
75
+ keyed on `(repo, "dependabot_pr", "#N")` with the operator's
76
+ verbatim answer. PR# regex handles both `PR #60` and bare `#60`
77
+ forms, dedupes, and tolerates questions with no PR# (e.g.
78
+ CodeQL-only) by writing nothing.
79
+ 2. Before building each sweep's prompt, `run_secops_all` pulls the
80
+ last 30 days of decisions for that repo (namespaced to
81
+ `operation="dependabot_pr"` so e.g. CodeQL-suppression decisions
82
+ don't leak into the Dependabot prompt) and threads them via
83
+ `ctx.extra['prior_decisions']` into `_build_prompt`, which renders
84
+ a `## Prior operator decisions (last 30 days)` block listing each
85
+ decision verbatim alongside the original question snippet so the
86
+ agent can detect a force-pushed PR that swapped the version bump
87
+ under the same PR number. The block carries instructions to act
88
+ on prior answers unless circumstances have materially changed
89
+ (different version bump, CI state flipped). Without this the
90
+ persistence layer was dead weight.
91
+
92
+ ### Fixed
93
+
94
+ - **Secops handles "no CI configured" without inventing options.**
95
+ Repos with no `.github/workflows/*` previously confused the agent
96
+ into freelance suggestions ("Want me to batch into a review PR?")
97
+ because the "auto-merge with passing CI" gate couldn't fire. Prompt
98
+ now explicitly directs: treat ALL Dependabot PRs as ASK regardless
99
+ of tier, and signal BLOCKED ONCE with a single consolidated question
100
+ per repo (not one per PR).
101
+ - **Secops escalates pre-existing CI failures instead of stalling PRs.**
102
+ When CI is failing on a check unrelated to the PR's package (e.g.
103
+ `pip-audit` flagging a transitive CVE in `idna` while the PR bumps
104
+ `boto3`), patch PRs were sitting stuck for weeks because the agent
105
+ reported the failure and moved on. Prompt now requires the agent to
106
+ signal BLOCKED with a one-line question naming the underlying issue
107
+ and a root-cause hint from `gh run view --log-failed`.
108
+ - **Secops scope discipline.** The prompt now enforces "exactly three
109
+ legitimate exits" per open PR (merge / leave / BLOCKED) and
110
+ explicitly forbids batching PRs into review PRs, opening tracking
111
+ issues, or performing manual reviews. Cuts off the freelance
112
+ options observed in production sweeps.
113
+
10
114
  ## [0.5.0] - 2026-05-11
11
115
 
12
116
  Minor release. Three changes on the secops path; together they take the
@@ -1143,7 +1247,10 @@ pipeline).
1143
1247
  per-phase implementation plans (Phase 0 through Phase 4).
1144
1248
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1145
1249
 
1146
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.1.3...HEAD
1250
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...HEAD
1251
+ [0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
1252
+ [0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
1253
+ [0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
1147
1254
  [0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
1148
1255
  [0.1.1]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.1
1149
1256
  [0.1.0]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.5.0
3
+ Version: 0.8.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/
@@ -139,3 +139,16 @@ repos: []
139
139
  # # Matching is case-insensitive. Defaults to ["manual", "operator",
140
140
  # # "instruction"]; override with an empty list to disable.
141
141
  # exclude_labels: ["manual", "operator", "instruction"]
142
+ # # Gates the plain-assignment trigger: when set, assignment alone
143
+ # # is no longer enough on its own — an issue must ALSO carry at
144
+ # # least one of these labels to be picked up. Does not affect
145
+ # # include_labels, which still triggers on a label match alone.
146
+ # # Matching is case-insensitive. Defaults to [] (assignment alone
147
+ # # is sufficient, same as today).
148
+ # require_labels: []
149
+ # # Hard cap, in seconds, on the single `ctrlrelay ci wait` call the
150
+ # # dev pipeline prompt runs before signaling DONE. Lower this to
151
+ # # match how long this repo's CI actually takes — no tokens are
152
+ # # generated while blocked in this call, so a slow default here
153
+ # # just adds idle wall-clock time. Defaults to 600 (10 minutes).
154
+ # ci_wait_timeout_seconds: 600
@@ -200,6 +200,8 @@ and ask the operator), or `never` (skip).
200
200
  | `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by someone else. Default (`false`) runs the dev pipeline only on issues you self-assigned. |
201
201
  | `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
202
202
  | `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
203
+ | `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
204
+ | `ci_wait_timeout_seconds` | `600` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
203
205
 
204
206
  The current secops and dev pipelines read these settings to bias their prompts
205
207
  to Claude — they're not enforced by hard-coded checks.
@@ -308,6 +310,67 @@ triage access was already able to push branches and trigger CI, so allowing
308
310
  them to opt an issue into the dev pipeline is a narrower extension, not a new
309
311
  vector.
310
312
 
313
+ ### repos[].automation.require_labels
314
+
315
+ Assignment-only ends up too loose on repos with a long history: issues
316
+ self-assigned as personal reminders or notes, unrelated to automation, get
317
+ replayed into the dev pipeline the first time the poller sees the repo (or
318
+ after any config change that clears `poller_state.json` for it). Some of
319
+ those issues aren't code tasks at all — "create an account with a
320
+ third-party service", "look into pricing" — and shouldn't go anywhere near
321
+ an unsupervised `claude -p ... --dangerously-skip-permissions` run.
322
+
323
+ `require_labels` closes that gap: when configured, bare assignment is no
324
+ longer sufficient by itself. An issue must be **both** assigned to the
325
+ operator **and** carry at least one of the configured labels to be picked
326
+ up via the assignment path.
327
+
328
+ ```yaml
329
+ repos:
330
+ - name: "your-org/your-repo"
331
+ local_path: "~/Projects/your-repo"
332
+ automation:
333
+ require_labels: ["ctrlrelay:auto"]
334
+ ```
335
+
336
+ - Default: `[]`. An empty list preserves today's behavior — assignment
337
+ alone is sufficient, no change for operators who haven't opted in.
338
+ - Matching is **case-insensitive**, same as `exclude_labels` / `include_labels`.
339
+ - This only tightens the **assignment** path. `include_labels` is
340
+ unaffected — a label match there still admits the issue regardless of
341
+ `require_labels`, since that path already treats the label as its own
342
+ trust signal.
343
+ - An issue that's assigned but missing the required label is left
344
+ **unmarked** (not added to `seen_issues`), so applying the label later
345
+ still surfaces it on a subsequent poll — nothing is lost, it's just not
346
+ picked up yet.
347
+ - `exclude_labels` is still checked first, same precedence as always.
348
+
349
+ ### repos[].automation.ci_wait_timeout_seconds
350
+
351
+ The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
352
+ --repo <repo> --timeout <N>` before signaling DONE — a single blocking call
353
+ that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
354
+ No tokens get generated while it's blocked; on a repo with a slow CI suite,
355
+ that one call can eat most of a session's wall-clock time.
356
+
357
+ ```yaml
358
+ repos:
359
+ - name: "your-org/your-repo"
360
+ local_path: "~/Projects/your-repo"
361
+ automation:
362
+ ci_wait_timeout_seconds: 300
363
+ ```
364
+
365
+ - Default: `600` (10 minutes).
366
+ - Lower it to match how long the repo's CI actually takes — a repo with a
367
+ 2-minute CI suite gains nothing from a 10-minute cap.
368
+ - A timeout here isn't a failure: `ctrlrelay ci wait` exits 2 (not 1) when
369
+ the deadline hits with checks still pending, and the prompt tells Claude
370
+ to treat that as acceptable and hand off rather than loop.
371
+ - This does not change the overall session timeout (`agent.default_timeout_seconds`,
372
+ default 1800s) — it only bounds the one `ci wait` call.
373
+
311
374
  ## schedules
312
375
 
313
376
  In-process cron jobs run by the poller daemon. All expressions are
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.5.0"
3
+ version = "0.8.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"
@@ -748,6 +748,7 @@ def run_dev(
748
748
  state_db=db,
749
749
  transport=None,
750
750
  contexts_dir=config.paths.contexts,
751
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
751
752
  )
752
753
 
753
754
  try:
@@ -1055,6 +1056,9 @@ def poller_start(
1055
1056
  include_labels_by_repo = {
1056
1057
  r.name: list(r.automation.include_labels) for r in config.repos
1057
1058
  }
1059
+ require_labels_by_repo = {
1060
+ r.name: list(r.automation.require_labels) for r in config.repos
1061
+ }
1058
1062
 
1059
1063
  poller = IssuePoller(
1060
1064
  github=github,
@@ -1064,6 +1068,7 @@ def poller_start(
1064
1068
  accept_foreign_assignments=accept_foreign,
1065
1069
  exclude_labels_by_repo=exclude_labels_by_repo,
1066
1070
  include_labels_by_repo=include_labels_by_repo,
1071
+ require_labels_by_repo=require_labels_by_repo,
1067
1072
  )
1068
1073
 
1069
1074
  # NOTE: first-run seeding moved into `_main()` so the APScheduler
@@ -1269,6 +1274,7 @@ def poller_start(
1269
1274
  state_db=state_db,
1270
1275
  transport=connected_transport,
1271
1276
  contexts_dir=config.paths.contexts,
1277
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
1272
1278
  )
1273
1279
 
1274
1280
  # Lock-conflict retry hook. The poller marks issues seen
@@ -1631,6 +1637,7 @@ def poller_start(
1631
1637
  if sweep_repo_cfg is not None
1632
1638
  else None
1633
1639
  ),
1640
+ question=row.get("question"),
1634
1641
  )
1635
1642
  elif pipeline_name == "dev":
1636
1643
  # Dev resume needs the repo's branch template
@@ -2486,6 +2493,14 @@ def setup(
2486
2493
  )
2487
2494
  if result.personalization_summary:
2488
2495
  console.print(f" personalization: {result.personalization_summary}")
2496
+ if result.personalization_failed:
2497
+ console.print(
2498
+ "\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
2499
+ "sync failed; the rest of setup completed but your "
2500
+ "personalization config/skills were not cloned. Check your "
2501
+ "git auth (`gh auth status`, `gh config get git_protocol`) "
2502
+ "and re-run `ctrlrelay personalization init` once it's fixed."
2503
+ )
2489
2504
  if result.daemon_units:
2490
2505
  console.print(" daemon unit files:")
2491
2506
  for p in result.daemon_units:
@@ -2497,11 +2512,10 @@ def setup(
2497
2512
  " systemctl --user daemon-reload && systemctl --user enable --now <unit>"
2498
2513
  " # Linux"
2499
2514
  )
2500
- # Clone failures must surface as a non-zero exit even when daemon
2501
- # units were rendered — automation watching exit code shouldn't
2502
- # mistake a partial setup for success. Codex review pass 3 caught
2503
- # the prior `elif` that masked this.
2504
- if result.failed:
2515
+ # Clone and personalization-sync failures must surface as a
2516
+ # non-zero exit even when daemon units were rendered — automation
2517
+ # watching exit code shouldn't mistake a partial setup for success.
2518
+ if result.failed or result.personalization_failed:
2505
2519
  raise typer.Exit(1)
2506
2520
 
2507
2521
 
@@ -183,6 +183,16 @@ class AutomationConfig(BaseModel):
183
183
  # Matching is case-insensitive. An issue that is both labeled AND
184
184
  # assigned is processed exactly once — not duplicated. See #80.
185
185
  include_labels: list[str] = Field(default_factory=list)
186
+ # Labels that gate the plain-assignment trigger. Default ``[]``
187
+ # preserves today's behavior (assignment alone is sufficient).
188
+ # When non-empty, bare assignment is no longer enough on its own:
189
+ # an issue must be assigned to the operator AND carry at least one
190
+ # of these labels to be picked up. This only tightens the
191
+ # assignment path — ``include_labels`` keeps triggering on label
192
+ # match alone regardless of ``require_labels``, and
193
+ # ``exclude_labels`` is still checked first. Matching is
194
+ # case-insensitive. See #139.
195
+ require_labels: list[str] = Field(default_factory=list)
186
196
  # Labels that route an issue to the task pipeline (run a command /
187
197
  # investigate / report findings via issue comment) instead of the
188
198
  # dev pipeline (branch + PR). Matching is case-insensitive; the
@@ -191,6 +201,13 @@ class AutomationConfig(BaseModel):
191
201
  # precedence so "not for the agent at all" overrides "agent does
192
202
  # this but differently".
193
203
  task_labels: list[str] = Field(default_factory=lambda: ["task"])
204
+ # Hard cap, in seconds, on a single `ctrlrelay ci wait` call the dev
205
+ # pipeline prompt tells Claude to run before signaling DONE. A repo
206
+ # with slow CI can eat most of a session's wall-clock time sitting
207
+ # in this one blocking call generating zero tokens; a fast-CI repo
208
+ # doesn't need anywhere near the 600s default. Lower this per-repo
209
+ # to match how long that repo's CI actually takes.
210
+ ci_wait_timeout_seconds: int = 600
194
211
 
195
212
 
196
213
  _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
@@ -84,6 +84,16 @@ class IssuePoller:
84
84
  or ``seen_issues``). A repo with an empty / missing ``include_labels``
85
85
  keeps the pre-#80 ``--assignee``-filtered query so we don't
86
86
  over-fetch.
87
+
88
+ ``require_labels_by_repo`` tightens the plain-assignment trigger
89
+ (see #139). When a repo has any require-labels configured, bare
90
+ assignment is no longer sufficient by itself — an issue must be
91
+ assigned to the operator AND carry at least one of the configured
92
+ labels to be admitted via the assignment path. ``include_labels``
93
+ is unaffected: a label_match there still admits the issue
94
+ regardless of ``require_labels``. An issue that's assigned but
95
+ missing the required label is left unmarked (not seen) so a later
96
+ label addition still surfaces it on a subsequent poll.
87
97
  """
88
98
 
89
99
  github: GitHubCLI
@@ -94,6 +104,7 @@ class IssuePoller:
94
104
  accept_foreign_assignments: set[str] = field(default_factory=set)
95
105
  exclude_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
96
106
  include_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
107
+ require_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
97
108
  # Per-repo consecutive-skip counter; populated at runtime by poll() /
98
109
  # seed_current(). Not persisted — intentionally resets on daemon
99
110
  # restart so an operator fix is exercised before we re-escalate.
@@ -343,6 +354,10 @@ class IssuePoller:
343
354
  label.lower()
344
355
  for label in self.exclude_labels_by_repo.get(repo, [])
345
356
  }
357
+ require_lowered = {
358
+ label.lower()
359
+ for label in self.require_labels_by_repo.get(repo, [])
360
+ }
346
361
  for issue in issues:
347
362
  # Per-issue guard so ONE malformed payload (missing 'number',
348
363
  # wrong type, non-dict entry) doesn't poison the remaining
@@ -412,6 +427,18 @@ class IssuePoller:
412
427
  is_assigned = self._issue_is_assigned_to(
413
428
  issue, self.username,
414
429
  )
430
+ if (
431
+ is_assigned
432
+ and require_lowered
433
+ and self._matched_require_label(
434
+ issue, require_lowered,
435
+ ) is None
436
+ ):
437
+ # Assigned but missing the required label
438
+ # (#139): the plain-assignment trigger doesn't
439
+ # count on this repo. label_match (below) can
440
+ # still admit the issue independently.
441
+ is_assigned = False
415
442
  if label_match is None and not is_assigned:
416
443
  # Defensive: a targeted query should never
417
444
  # return an issue that matches neither trigger,
@@ -423,7 +450,13 @@ class IssuePoller:
423
450
  else:
424
451
  # Server-side ``--assignee`` already filtered; by
425
452
  # construction every issue reaching here is
426
- # assignment-triggered.
453
+ # assignment-triggered — unless require_labels
454
+ # (#139) is configured, in which case the missing
455
+ # label leaves the issue unmarked for a later poll.
456
+ if require_lowered and self._matched_require_label(
457
+ issue, require_lowered,
458
+ ) is None:
459
+ continue
427
460
  is_assigned = True
428
461
 
429
462
  if label_match is not None:
@@ -584,6 +617,27 @@ class IssuePoller:
584
617
  return name
585
618
  return None
586
619
 
620
+ @staticmethod
621
+ def _matched_require_label(
622
+ issue: dict[str, Any], require_lowered: set[str]
623
+ ) -> str | None:
624
+ """Return the first issue label that matches ``require_lowered``.
625
+
626
+ Mirrors ``_matched_exclude_label`` / ``_matched_include_label``.
627
+ Used to gate the plain-assignment trigger when a repo has
628
+ ``require_labels`` configured. See #139.
629
+ """
630
+ if not require_lowered:
631
+ return None
632
+ for label in issue.get("labels") or []:
633
+ if isinstance(label, dict):
634
+ name = label.get("name", "")
635
+ else:
636
+ name = str(label)
637
+ if name and name.lower() in require_lowered:
638
+ return name
639
+ return None
640
+
587
641
  @staticmethod
588
642
  def _issue_is_assigned_to(issue: dict[str, Any], username: str) -> bool:
589
643
  """Check whether ``username`` is listed in the issue's assignees.
@@ -726,6 +780,10 @@ class IssuePoller:
726
780
  self._clear_repo_failure(repo)
727
781
  seen_for_repo = self.seen_issues.setdefault(repo, set())
728
782
  include_lowered = {label.lower() for label in include_labels}
783
+ require_lowered = {
784
+ label.lower()
785
+ for label in self.require_labels_by_repo.get(repo, [])
786
+ }
729
787
  for issue in issues:
730
788
  try:
731
789
  number = int(issue["number"])
@@ -776,11 +834,25 @@ class IssuePoller:
776
834
  seen_for_repo.add(number)
777
835
  continue
778
836
  if self_assigned:
837
+ if require_lowered and self._matched_require_label(
838
+ issue, require_lowered,
839
+ ) is None:
840
+ # Assigned but missing the required label
841
+ # (#139): leave unseeded so a later
842
+ # label-add still surfaces it on the next
843
+ # poll.
844
+ continue
779
845
  seen_for_repo.add(number)
780
846
  else:
781
847
  # Pre-#80 behavior: server-side --assignee already
782
848
  # filtered, seed everything so first poll doesn't
783
- # flood.
849
+ # flood — unless require_labels (#139) is
850
+ # configured, in which case an issue missing the
851
+ # required label is left unseeded.
852
+ if require_lowered and self._matched_require_label(
853
+ issue, require_lowered,
854
+ ) is None:
855
+ continue
784
856
  seen_for_repo.add(number)
785
857
  self._save_state_best_effort()
786
858
 
@@ -386,6 +386,80 @@ class StateDB:
386
386
  self._conn.commit()
387
387
  return cursor.rowcount > 0
388
388
 
389
+ # Automation decisions (operator answers persisted across sweeps)
390
+
391
+ def record_automation_decision(
392
+ self,
393
+ *,
394
+ repo: str,
395
+ operation: str,
396
+ item_id: str,
397
+ decision: str,
398
+ decided_by: str = "operator",
399
+ context: str | None = None,
400
+ policy: str = "",
401
+ ) -> None:
402
+ """Record an operator decision so future sweeps can avoid
403
+ re-asking the same question.
404
+
405
+ ``operation`` namespaces the decision domain
406
+ (e.g. ``"dependabot_pr"`` or ``"codeql_alert"``); ``item_id``
407
+ is the specific item the decision applies to (e.g. ``"#60"``
408
+ for PR #60). ``decision`` is the operator's verbatim answer —
409
+ the agent that reads this back interprets it; we don't try to
410
+ normalize to yes/no here because Telegram replies are free-form.
411
+ """
412
+ self._conn.execute(
413
+ """INSERT INTO automation_decisions
414
+ (repo, operation, policy, item_id, decision,
415
+ decided_by, decided_at, context)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
417
+ (repo, operation, policy, item_id, decision,
418
+ decided_by, int(time.time()), context),
419
+ )
420
+ self._conn.commit()
421
+
422
+ def list_recent_automation_decisions(
423
+ self,
424
+ repo: str,
425
+ *,
426
+ operation: str | None = None,
427
+ since_ts: int | None = None,
428
+ limit: int = 100,
429
+ ) -> list[dict[str, Any]]:
430
+ """Recent operator decisions for this repo, newest first.
431
+
432
+ ``operation`` namespaces the read so a Dependabot-PR consumer
433
+ only sees ``dependabot_pr`` rows, not e.g. ``codeql_alert``
434
+ decisions also tracked in the same repo. Without this filter,
435
+ a "suppress this CVE" answer for a CodeQL alert would render
436
+ into the Dependabot prompt as a prior-PR decision and the
437
+ agent could act on it. Defaults to ``None`` (all operations)
438
+ to preserve the original behaviour for callers that want a
439
+ full audit view.
440
+
441
+ ``since_ts`` (unix seconds) filters to decisions made
442
+ at-or-after that point — used by the secops prompt builder to
443
+ inject a 30-day rolling window into the agent's context so the
444
+ agent can act on prior decisions instead of asking again.
445
+ """
446
+ clauses = ["repo = ?"]
447
+ params: list[Any] = [repo]
448
+ if operation is not None:
449
+ clauses.append("operation = ?")
450
+ params.append(operation)
451
+ if since_ts is not None:
452
+ clauses.append("decided_at >= ?")
453
+ params.append(since_ts)
454
+ sql = (
455
+ "SELECT * FROM automation_decisions WHERE "
456
+ + " AND ".join(clauses)
457
+ + " ORDER BY decided_at DESC LIMIT ?"
458
+ )
459
+ params.append(limit)
460
+ rows = self._conn.execute(sql, tuple(params)).fetchall()
461
+ return [dict(row) for row in rows]
462
+
389
463
  # PR watches (durable, cross-restart)
390
464
 
391
465
  def add_pr_watch(
@@ -0,0 +1,47 @@
1
+ """Detect the git transport protocol the operator has configured for
2
+ GitHub via the ``gh`` CLI.
3
+
4
+ ``ctrlrelay`` clones a handful of GitHub repos on the operator's
5
+ behalf (project repos, the personalization repo). Hardcoding HTTPS
6
+ for these clones breaks operators who've set ``gh config set
7
+ git_protocol ssh`` and have no HTTPS credential helper wired up —
8
+ ``git`` then fails with "could not read Username" instead of using
9
+ the SSH key ``gh`` already knows about.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+
16
+ _VALID_PROTOCOLS = ("ssh", "https")
17
+
18
+
19
+ def detect_git_protocol(*, default: str = "https") -> str:
20
+ """Return ``"ssh"`` or ``"https"``, per ``gh config get git_protocol``.
21
+
22
+ Falls back to ``default`` whenever ``gh`` isn't installed, isn't
23
+ configured, or returns something unexpected — this is a
24
+ convenience read, not a hard requirement, so any failure here
25
+ should degrade to the previous hardcoded-HTTPS behavior rather
26
+ than blocking a clone.
27
+ """
28
+ try:
29
+ proc = subprocess.run(
30
+ ["gh", "config", "get", "git_protocol"],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=5,
34
+ )
35
+ except (OSError, subprocess.SubprocessError):
36
+ return default
37
+ value = proc.stdout.strip().lower()
38
+ if proc.returncode == 0 and value in _VALID_PROTOCOLS:
39
+ return value
40
+ return default
41
+
42
+
43
+ def github_clone_url(repo: str, *, protocol: str) -> str:
44
+ """Build a clone URL for ``owner/repo`` under the given protocol."""
45
+ if protocol == "ssh":
46
+ return f"git@github.com:{repo}.git"
47
+ return f"https://github.com/{repo}.git"
@@ -178,7 +178,17 @@ def write_units(
178
178
  silently overwriting an operator's customised plist would be a foot-gun.
179
179
  Returns the list of paths actually written. Raises ``FileExistsError``
180
180
  if any target exists and overwrite is False.
181
+
182
+ Also ensures ``~/.ctrlrelay/logs/`` exists — both the launchd and
183
+ systemd templates set StandardOutput/StandardError paths under it,
184
+ and systemd refuses to start a unit at all (EXIT_STDOUT, code 209)
185
+ when the directory is missing; combined with ``Restart=always``
186
+ that's an immediate crash-loop (#137).
187
+
188
+ Unit files are chmod'd ``0600`` after writing since they embed
189
+ ``CTRLRELAY_TELEGRAM_TOKEN`` as plaintext.
181
190
  """
191
+ (Path.home() / ".ctrlrelay" / "logs").mkdir(parents=True, exist_ok=True)
182
192
  written: list[Path] = []
183
193
  for unit in units:
184
194
  unit.target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -188,5 +198,6 @@ def write_units(
188
198
  "pass --force to replace it"
189
199
  )
190
200
  unit.target_path.write_text(unit.content, encoding="utf-8")
201
+ unit.target_path.chmod(0o600)
191
202
  written.append(unit.target_path)
192
203
  return written