ctrlrelay 0.4.1__tar.gz → 0.7.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 (115) hide show
  1. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/build.yml +1 -1
  2. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/pages.yml +1 -1
  3. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/publish.yml +1 -1
  4. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/test.yml +1 -1
  5. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CHANGELOG.md +162 -1
  6. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/PKG-INFO +2 -2
  7. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/config/orchestrator.yaml.example +7 -0
  8. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/configuration.md +37 -0
  9. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/pyproject.toml +1 -1
  10. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/cli.py +47 -6
  11. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/config.py +10 -0
  12. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/poller.py +74 -2
  13. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/state.py +74 -0
  14. ctrlrelay-0.7.0/src/ctrlrelay/gh_protocol.py +47 -0
  15. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/install.py +11 -0
  16. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/manager.py +8 -1
  17. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/secops.py +372 -7
  18. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/setup.py +11 -7
  19. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/socket_client.py +11 -0
  20. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_config.py +96 -0
  21. ctrlrelay-0.7.0/tests/test_gh_protocol.py +65 -0
  22. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_install.py +39 -0
  23. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_personalization.py +27 -0
  24. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_poller.py +150 -0
  25. ctrlrelay-0.7.0/tests/test_secops_pipeline.py +1432 -0
  26. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_setup.py +140 -6
  27. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_state.py +129 -0
  28. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_transport.py +101 -0
  29. ctrlrelay-0.4.1/tests/test_secops_pipeline.py +0 -555
  30. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  31. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  32. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  33. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/dependabot.yml +0 -0
  34. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/cla.yml +0 -0
  35. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.gitignore +0 -0
  36. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  37. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CONTRIBUTING.md +0 -0
  38. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/LICENSE +0 -0
  39. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/README.md +0 -0
  40. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/SECURITY.md +0 -0
  41. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/Gemfile +0 -0
  42. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/_config.yml +0 -0
  43. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/architecture.md +0 -0
  44. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/bridge.md +0 -0
  45. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/cli.md +0 -0
  46. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/development.md +0 -0
  47. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/feedback-loop.md +0 -0
  48. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/getting-started.md +0 -0
  49. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/index.md +0 -0
  50. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/operations.md +0 -0
  51. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/personalization.md +0 -0
  52. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/__init__.py +0 -0
  53. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  54. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  55. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  56. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/server.py +0 -0
  57. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  58. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/__init__.py +0 -0
  59. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/audit.py +0 -0
  60. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  61. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  62. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/github.py +0 -0
  63. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/obs.py +0 -0
  64. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  65. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  66. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/scheduler.py +0 -0
  67. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/worktree.py +0 -0
  68. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  69. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/client.py +0 -0
  70. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  71. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/paths.py +0 -0
  72. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  73. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/base.py +0 -0
  74. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  75. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  76. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/task.py +0 -0
  77. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/__init__.py +0 -0
  78. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  79. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  80. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  81. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  82. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  83. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  84. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  85. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/__init__.py +0 -0
  86. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/base.py +0 -0
  87. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  88. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/__init__.py +0 -0
  89. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/conftest.py +0 -0
  90. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_audit.py +0 -0
  91. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_bridge_protocol.py +0 -0
  92. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_bridge_server.py +0 -0
  93. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_checkpoint.py +0 -0
  94. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_ci_wait.py +0 -0
  95. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_dev.py +0 -0
  96. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_repos.py +0 -0
  97. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_secops.py +0 -0
  98. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_start.py +0 -0
  99. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_version.py +0 -0
  100. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dashboard_client.py +0 -0
  101. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dev_integration.py +0 -0
  102. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dev_pipeline.py +0 -0
  103. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dispatcher.py +0 -0
  104. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_docs_site.py +0 -0
  105. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_github.py +0 -0
  106. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_obs.py +0 -0
  107. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pipeline_base.py +0 -0
  108. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_post_merge.py +0 -0
  109. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pr_verifier.py +0 -0
  110. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pr_watcher.py +0 -0
  111. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_scheduler.py +0 -0
  112. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_secops_integration.py +0 -0
  113. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_task_pipeline.py +0 -0
  114. {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_telegram_handler.py +0 -0
  115. {ctrlrelay-0.4.1 → ctrlrelay-0.7.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,165 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-04
11
+
12
+ ### Added
13
+
14
+ - **`require_labels`: an AND-gate between assignment and a label,
15
+ closing the gap `include_labels` (#80) couldn't cover.** `exclude_labels`
16
+ is a block-list and `include_labels` is an OR-alternate trigger, but
17
+ neither could express "only pick this up if it's assigned to me AND
18
+ carries a specific label." On a repo with a long history of
19
+ self-assigned issues used as personal reminders, that meant the poller
20
+ replayed the whole backlog as new work the first time it saw the repo —
21
+ including issues that weren't code tasks at all. `require_labels`
22
+ (default `[]`, no behavior change) tightens the plain-assignment path
23
+ only: when set, an issue needs both the assignment and a matching label.
24
+ `include_labels` keeps triggering on a label match alone regardless.
25
+ An assigned issue missing the required label is left unmarked so a
26
+ later label addition still surfaces it. See #139.
27
+
28
+ ### Fixed
29
+
30
+ - **`ctrlrelay setup --install-daemons` on a fresh SSH-only machine:
31
+ personalization clone ignored the configured git protocol, and
32
+ generated daemon units crash-looped.** The personalization repo
33
+ clone (setup pre-scan and `PersonalizationManager.init`) hardcoded
34
+ an HTTPS URL regardless of `gh`'s configured `git_protocol`,
35
+ so SSH-only operators hit `could not read Username` with the
36
+ failure buried in a single warning line while setup still exited
37
+ 0. `ctrlrelay.gh_protocol` now reads `gh`'s configured protocol and
38
+ builds the matching clone URL; a personalization failure flips
39
+ `SetupResult.personalization_failed` so the CLI prints an ACTION
40
+ NEEDED banner and exits non-zero instead of silently continuing.
41
+ Separately, generated launchd/systemd units point
42
+ `StandardOutput`/`StandardError` at `~/.ctrlrelay/logs/`, which
43
+ setup never created — systemd refuses to start a unit with a
44
+ missing log dir (`EXIT_STDOUT`, 209), and with `Restart=always`
45
+ that's an immediate crash-loop. `write_units` now creates the log
46
+ dir up front and chmods unit files `0600` since they embed
47
+ `CTRLRELAY_TELEGRAM_TOKEN` in plaintext. See #137.
48
+
49
+ ## [0.6.0] - 2026-06-10
50
+
51
+ ### Added
52
+
53
+ - **Secops sweeps now remember operator decisions across days.** The
54
+ `automation_decisions` table had a schema but no producer or consumer
55
+ — every 6am sweep re-asked about the same Dependabot PRs the operator
56
+ had already answered, drowning Telegram. Wired both halves:
57
+ 1. When the BLOCKED loop in `run_secops_all` (or the out-of-band
58
+ `resume_secops_from_pending` sweeper) receives an answer, regex
59
+ extracts every PR# from the question and records one row per PR#
60
+ keyed on `(repo, "dependabot_pr", "#N")` with the operator's
61
+ verbatim answer. PR# regex handles both `PR #60` and bare `#60`
62
+ forms, dedupes, and tolerates questions with no PR# (e.g.
63
+ CodeQL-only) by writing nothing.
64
+ 2. Before building each sweep's prompt, `run_secops_all` pulls the
65
+ last 30 days of decisions for that repo (namespaced to
66
+ `operation="dependabot_pr"` so e.g. CodeQL-suppression decisions
67
+ don't leak into the Dependabot prompt) and threads them via
68
+ `ctx.extra['prior_decisions']` into `_build_prompt`, which renders
69
+ a `## Prior operator decisions (last 30 days)` block listing each
70
+ decision verbatim alongside the original question snippet so the
71
+ agent can detect a force-pushed PR that swapped the version bump
72
+ under the same PR number. The block carries instructions to act
73
+ on prior answers unless circumstances have materially changed
74
+ (different version bump, CI state flipped). Without this the
75
+ persistence layer was dead weight.
76
+
77
+ ### Fixed
78
+
79
+ - **Secops handles "no CI configured" without inventing options.**
80
+ Repos with no `.github/workflows/*` previously confused the agent
81
+ into freelance suggestions ("Want me to batch into a review PR?")
82
+ because the "auto-merge with passing CI" gate couldn't fire. Prompt
83
+ now explicitly directs: treat ALL Dependabot PRs as ASK regardless
84
+ of tier, and signal BLOCKED ONCE with a single consolidated question
85
+ per repo (not one per PR).
86
+ - **Secops escalates pre-existing CI failures instead of stalling PRs.**
87
+ When CI is failing on a check unrelated to the PR's package (e.g.
88
+ `pip-audit` flagging a transitive CVE in `idna` while the PR bumps
89
+ `boto3`), patch PRs were sitting stuck for weeks because the agent
90
+ reported the failure and moved on. Prompt now requires the agent to
91
+ signal BLOCKED with a one-line question naming the underlying issue
92
+ and a root-cause hint from `gh run view --log-failed`.
93
+ - **Secops scope discipline.** The prompt now enforces "exactly three
94
+ legitimate exits" per open PR (merge / leave / BLOCKED) and
95
+ explicitly forbids batching PRs into review PRs, opening tracking
96
+ issues, or performing manual reviews. Cuts off the freelance
97
+ options observed in production sweeps.
98
+
99
+ ## [0.5.0] - 2026-05-11
100
+
101
+ Minor release. Three changes on the secops path; together they take the
102
+ secops sweep from "silently drops operator decisions" to "respects per-repo
103
+ policy and reaches the operator on every blocked decision".
104
+
105
+ ### Fixed
106
+
107
+ - **Secops BLOCKED questions now reach the operator via Telegram.**
108
+ Three discrete bugs were stacking to drop every blocked secops session
109
+ silently (#131):
110
+ 1. `run_secops_all` never called `transport.ask()` after a blocked
111
+ pipeline result. The DB row was marked `status='blocked'` and a
112
+ `pending_resumes` entry was inserted, but the question went nowhere.
113
+ Now mirrors the dev/task pipelines: post the question, await an
114
+ answer, resume — up to `DEFAULT_MAX_BLOCKED_ROUNDS=5` rounds. On
115
+ transport failure preserves `blocked=True` so the existing
116
+ persistence-on-blocked branch fires.
117
+ 2. `ctrlrelay run secops` (manual CLI) was passing `transport=None`,
118
+ so even with the dispatch loop in place, the `is not None` gate
119
+ short-circuited and questions still went to pending_resumes
120
+ instead of Telegram. Now builds a `SocketTransport` when the
121
+ bridge socket is up, mirroring the scheduled-cron path.
122
+ 3. `SocketTransport._receive_loop` resolved the per-request future on
123
+ the FIRST message matching `request_id`. The bridge sends two
124
+ messages for an ASK: an intermediate `ACK(status="pending")` then
125
+ a terminal `ANSWER`. Receiving the ACK fulfilled the future early
126
+ and `ask()` raised `TransportError("Unexpected response: BridgeOp.ACK")`.
127
+ Now skips `ACK(status="pending")` and waits for `ANSWER`/`ERROR`.
128
+
129
+ Real-world impact: an 86-repo secops sweep with 21 sessions hitting
130
+ BLOCKED produced zero Telegram messages pre-fix. Post-fix, every
131
+ blocked session reaches the operator with a structured question.
132
+
133
+ ### Added
134
+
135
+ - **Auto-merge operator-authored `.github/dependabot.yml`-only PRs.**
136
+ The secops agent's policy treated all operator-authored PRs as needing
137
+ explicit approval, even ones that ONLY add an ecosystem entry to
138
+ `.github/dependabot.yml`. These are the prerequisite PRs the operator
139
+ files when bulk-enabling Dependabot across repos with branch protection,
140
+ and they sat indefinitely (#132). The carve-out is narrow and
141
+ multi-gated:
142
+ 1. **Author check**: `author.login == $OPERATOR` (derived from
143
+ `gh api user --jq .login`) — collaborators, GitHub apps, or
144
+ external contributors are NOT eligible even for dependabot.yml-only.
145
+ 2. **Diff check**: `gh pr diff` must be PURELY ADDITIVE — any line
146
+ beginning with `-` (other than `---` file headers) signals a
147
+ deletion or modification of existing config -> BLOCKED.
148
+ 3. **CI check**: `gh pr checks` must all pass — a PR adding invalid
149
+ YAML can pass author+diff and still break Dependabot for the repo
150
+ if merged without this gate.
151
+
152
+ All three must pass before merge. Any one failing -> BLOCKED.
153
+
154
+ - **Per-repo `automation:` policy now drives the secops agent.**
155
+ `orchestrator.yaml`'s per-repo `automation` block (defining
156
+ `dependabot_patch`, `dependabot_minor`, `dependabot_major` as
157
+ auto/ask/never) was decorative — the secops prompt had hardcoded
158
+ prose that ignored it (#133). Now `_build_prompt` accepts an
159
+ `AutomationConfig` and renders per-tier directives like
160
+ "patch updates: AUTO-MERGE", "minor updates: ASK", "major updates: NEVER"
161
+ per repo. `run_secops_all` puts `repo_config.automation` in
162
+ `ctx.extra`; `resume_secops_from_pending` accepts an `automation`
163
+ kwarg; the pending-resume sweeper in `cli.py` looks up the per-repo
164
+ policy and threads it on resume.
165
+
166
+ Operators can now set a sensitive repo to `dependabot_minor: never`
167
+ and the agent will actually respect it.
168
+
10
169
  ## [0.4.1] - 2026-05-08
11
170
 
12
171
  Patch release. Two follow-ups against the v0.4.0 setup flow surfaced
@@ -1073,7 +1232,9 @@ pipeline).
1073
1232
  per-phase implementation plans (Phase 0 through Phase 4).
1074
1233
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1075
1234
 
1076
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.1.3...HEAD
1235
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...HEAD
1236
+ [0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
1237
+ [0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
1077
1238
  [0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
1078
1239
  [0.1.1]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.1
1079
1240
  [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.4.1
3
+ Version: 0.7.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,10 @@ 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: []
@@ -200,6 +200,7 @@ 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. |
203
204
 
204
205
  The current secops and dev pipelines read these settings to bias their prompts
205
206
  to Claude — they're not enforced by hard-coded checks.
@@ -308,6 +309,42 @@ triage access was already able to push branches and trigger CI, so allowing
308
309
  them to opt an issue into the dev pipeline is a narrower extension, not a new
309
310
  vector.
310
311
 
312
+ ### repos[].automation.require_labels
313
+
314
+ Assignment-only ends up too loose on repos with a long history: issues
315
+ self-assigned as personal reminders or notes, unrelated to automation, get
316
+ replayed into the dev pipeline the first time the poller sees the repo (or
317
+ after any config change that clears `poller_state.json` for it). Some of
318
+ those issues aren't code tasks at all — "create an account with a
319
+ third-party service", "look into pricing" — and shouldn't go anywhere near
320
+ an unsupervised `claude -p ... --dangerously-skip-permissions` run.
321
+
322
+ `require_labels` closes that gap: when configured, bare assignment is no
323
+ longer sufficient by itself. An issue must be **both** assigned to the
324
+ operator **and** carry at least one of the configured labels to be picked
325
+ up via the assignment path.
326
+
327
+ ```yaml
328
+ repos:
329
+ - name: "your-org/your-repo"
330
+ local_path: "~/Projects/your-repo"
331
+ automation:
332
+ require_labels: ["ctrlrelay:auto"]
333
+ ```
334
+
335
+ - Default: `[]`. An empty list preserves today's behavior — assignment
336
+ alone is sufficient, no change for operators who haven't opted in.
337
+ - Matching is **case-insensitive**, same as `exclude_labels` / `include_labels`.
338
+ - This only tightens the **assignment** path. `include_labels` is
339
+ unaffected — a label match there still admits the issue regardless of
340
+ `require_labels`, since that path already treats the label as its own
341
+ trust signal.
342
+ - An issue that's assigned but missing the required label is left
343
+ **unmarked** (not added to `seen_issues`), so applying the label later
344
+ still surfaces it on a subsequent poll — nothing is lost, it's just not
345
+ picked up yet.
346
+ - `exclude_labels` is still checked first, same precedence as always.
347
+
311
348
  ## schedules
312
349
 
313
350
  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.4.1"
3
+ version = "0.7.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"
@@ -593,6 +593,29 @@ def run_secops(
593
593
  console.print(f"Running secops on {len(repos)} repo(s)...")
594
594
 
595
595
  async def _run():
596
+ # Build a transport so secops blocked questions reach Telegram.
597
+ # Without this, agents that end BLOCKED_NEEDS_INPUT silently land
598
+ # in the DB+pending_resumes and the operator never sees the
599
+ # question (mirror of the wiring in the scheduled path).
600
+ transport = None
601
+ if (
602
+ config.transport.type.value == "telegram"
603
+ and config.transport.telegram
604
+ ):
605
+ from ctrlrelay.transports import SocketTransport
606
+ sock = config.transport.telegram.socket_path.expanduser().resolve()
607
+ if sock.exists():
608
+ try:
609
+ candidate = SocketTransport(sock)
610
+ await candidate.connect()
611
+ transport = candidate
612
+ except Exception as e:
613
+ console.print(
614
+ f"[yellow]Bridge transport connect failed ({e}) — "
615
+ f"running without notifications. Blocked sessions "
616
+ f"will land in pending_resumes for later resume.[/yellow]"
617
+ )
618
+
596
619
  return await run_secops_all(
597
620
  repos=repos,
598
621
  dispatcher=dispatcher,
@@ -600,7 +623,7 @@ def run_secops(
600
623
  worktree=worktree,
601
624
  dashboard=dashboard,
602
625
  state_db=db,
603
- transport=None,
626
+ transport=transport,
604
627
  contexts_dir=config.paths.contexts,
605
628
  )
606
629
 
@@ -1032,6 +1055,9 @@ def poller_start(
1032
1055
  include_labels_by_repo = {
1033
1056
  r.name: list(r.automation.include_labels) for r in config.repos
1034
1057
  }
1058
+ require_labels_by_repo = {
1059
+ r.name: list(r.automation.require_labels) for r in config.repos
1060
+ }
1035
1061
 
1036
1062
  poller = IssuePoller(
1037
1063
  github=github,
@@ -1041,6 +1067,7 @@ def poller_start(
1041
1067
  accept_foreign_assignments=accept_foreign,
1042
1068
  exclude_labels_by_repo=exclude_labels_by_repo,
1043
1069
  include_labels_by_repo=include_labels_by_repo,
1070
+ require_labels_by_repo=require_labels_by_repo,
1044
1071
  )
1045
1072
 
1046
1073
  # NOTE: first-run seeding moved into `_main()` so the APScheduler
@@ -1591,6 +1618,7 @@ def poller_start(
1591
1618
 
1592
1619
  try:
1593
1620
  if pipeline_name == "secops":
1621
+ sweep_repo_cfg = repo_cfg_by_name.get(repo)
1594
1622
  result = await resume_secops_from_pending(
1595
1623
  session_id=session_id,
1596
1624
  repo=repo,
@@ -1602,6 +1630,12 @@ def poller_start(
1602
1630
  state_db=state_db,
1603
1631
  transport=sweeper_transport,
1604
1632
  contexts_dir=config.paths.contexts,
1633
+ automation=(
1634
+ sweep_repo_cfg.automation
1635
+ if sweep_repo_cfg is not None
1636
+ else None
1637
+ ),
1638
+ question=row.get("question"),
1605
1639
  )
1606
1640
  elif pipeline_name == "dev":
1607
1641
  # Dev resume needs the repo's branch template
@@ -2457,6 +2491,14 @@ def setup(
2457
2491
  )
2458
2492
  if result.personalization_summary:
2459
2493
  console.print(f" personalization: {result.personalization_summary}")
2494
+ if result.personalization_failed:
2495
+ console.print(
2496
+ "\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
2497
+ "sync failed; the rest of setup completed but your "
2498
+ "personalization config/skills were not cloned. Check your "
2499
+ "git auth (`gh auth status`, `gh config get git_protocol`) "
2500
+ "and re-run `ctrlrelay personalization init` once it's fixed."
2501
+ )
2460
2502
  if result.daemon_units:
2461
2503
  console.print(" daemon unit files:")
2462
2504
  for p in result.daemon_units:
@@ -2468,11 +2510,10 @@ def setup(
2468
2510
  " systemctl --user daemon-reload && systemctl --user enable --now <unit>"
2469
2511
  " # Linux"
2470
2512
  )
2471
- # Clone failures must surface as a non-zero exit even when daemon
2472
- # units were rendered — automation watching exit code shouldn't
2473
- # mistake a partial setup for success. Codex review pass 3 caught
2474
- # the prior `elif` that masked this.
2475
- if result.failed:
2513
+ # Clone and personalization-sync failures must surface as a
2514
+ # non-zero exit even when daemon units were rendered — automation
2515
+ # watching exit code shouldn't mistake a partial setup for success.
2516
+ if result.failed or result.personalization_failed:
2476
2517
  raise typer.Exit(1)
2477
2518
 
2478
2519
 
@@ -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
@@ -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(