ctrlrelay 0.4.0__tar.gz → 0.5.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.4.0 → ctrlrelay-0.5.0}/CHANGELOG.md +98 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/PKG-INFO +1 -1
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/pyproject.toml +1 -1
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/cli.py +40 -1
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/secops.py +156 -7
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/setup.py +185 -5
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/socket_client.py +11 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_secops_pipeline.py +392 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_setup.py +280 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_transport.py +97 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.gitignore +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/LICENSE +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/README.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/SECURITY.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/cli.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/configuration.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/development.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/index.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/operations.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-05-11
|
|
11
|
+
|
|
12
|
+
Minor release. Three changes on the secops path; together they take the
|
|
13
|
+
secops sweep from "silently drops operator decisions" to "respects per-repo
|
|
14
|
+
policy and reaches the operator on every blocked decision".
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Secops BLOCKED questions now reach the operator via Telegram.**
|
|
19
|
+
Three discrete bugs were stacking to drop every blocked secops session
|
|
20
|
+
silently (#131):
|
|
21
|
+
1. `run_secops_all` never called `transport.ask()` after a blocked
|
|
22
|
+
pipeline result. The DB row was marked `status='blocked'` and a
|
|
23
|
+
`pending_resumes` entry was inserted, but the question went nowhere.
|
|
24
|
+
Now mirrors the dev/task pipelines: post the question, await an
|
|
25
|
+
answer, resume — up to `DEFAULT_MAX_BLOCKED_ROUNDS=5` rounds. On
|
|
26
|
+
transport failure preserves `blocked=True` so the existing
|
|
27
|
+
persistence-on-blocked branch fires.
|
|
28
|
+
2. `ctrlrelay run secops` (manual CLI) was passing `transport=None`,
|
|
29
|
+
so even with the dispatch loop in place, the `is not None` gate
|
|
30
|
+
short-circuited and questions still went to pending_resumes
|
|
31
|
+
instead of Telegram. Now builds a `SocketTransport` when the
|
|
32
|
+
bridge socket is up, mirroring the scheduled-cron path.
|
|
33
|
+
3. `SocketTransport._receive_loop` resolved the per-request future on
|
|
34
|
+
the FIRST message matching `request_id`. The bridge sends two
|
|
35
|
+
messages for an ASK: an intermediate `ACK(status="pending")` then
|
|
36
|
+
a terminal `ANSWER`. Receiving the ACK fulfilled the future early
|
|
37
|
+
and `ask()` raised `TransportError("Unexpected response: BridgeOp.ACK")`.
|
|
38
|
+
Now skips `ACK(status="pending")` and waits for `ANSWER`/`ERROR`.
|
|
39
|
+
|
|
40
|
+
Real-world impact: an 86-repo secops sweep with 21 sessions hitting
|
|
41
|
+
BLOCKED produced zero Telegram messages pre-fix. Post-fix, every
|
|
42
|
+
blocked session reaches the operator with a structured question.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Auto-merge operator-authored `.github/dependabot.yml`-only PRs.**
|
|
47
|
+
The secops agent's policy treated all operator-authored PRs as needing
|
|
48
|
+
explicit approval, even ones that ONLY add an ecosystem entry to
|
|
49
|
+
`.github/dependabot.yml`. These are the prerequisite PRs the operator
|
|
50
|
+
files when bulk-enabling Dependabot across repos with branch protection,
|
|
51
|
+
and they sat indefinitely (#132). The carve-out is narrow and
|
|
52
|
+
multi-gated:
|
|
53
|
+
1. **Author check**: `author.login == $OPERATOR` (derived from
|
|
54
|
+
`gh api user --jq .login`) — collaborators, GitHub apps, or
|
|
55
|
+
external contributors are NOT eligible even for dependabot.yml-only.
|
|
56
|
+
2. **Diff check**: `gh pr diff` must be PURELY ADDITIVE — any line
|
|
57
|
+
beginning with `-` (other than `---` file headers) signals a
|
|
58
|
+
deletion or modification of existing config -> BLOCKED.
|
|
59
|
+
3. **CI check**: `gh pr checks` must all pass — a PR adding invalid
|
|
60
|
+
YAML can pass author+diff and still break Dependabot for the repo
|
|
61
|
+
if merged without this gate.
|
|
62
|
+
|
|
63
|
+
All three must pass before merge. Any one failing -> BLOCKED.
|
|
64
|
+
|
|
65
|
+
- **Per-repo `automation:` policy now drives the secops agent.**
|
|
66
|
+
`orchestrator.yaml`'s per-repo `automation` block (defining
|
|
67
|
+
`dependabot_patch`, `dependabot_minor`, `dependabot_major` as
|
|
68
|
+
auto/ask/never) was decorative — the secops prompt had hardcoded
|
|
69
|
+
prose that ignored it (#133). Now `_build_prompt` accepts an
|
|
70
|
+
`AutomationConfig` and renders per-tier directives like
|
|
71
|
+
"patch updates: AUTO-MERGE", "minor updates: ASK", "major updates: NEVER"
|
|
72
|
+
per repo. `run_secops_all` puts `repo_config.automation` in
|
|
73
|
+
`ctx.extra`; `resume_secops_from_pending` accepts an `automation`
|
|
74
|
+
kwarg; the pending-resume sweeper in `cli.py` looks up the per-repo
|
|
75
|
+
policy and threads it on resume.
|
|
76
|
+
|
|
77
|
+
Operators can now set a sensitive repo to `dependabot_minor: never`
|
|
78
|
+
and the agent will actually respect it.
|
|
79
|
+
|
|
80
|
+
## [0.4.1] - 2026-05-08
|
|
81
|
+
|
|
82
|
+
Patch release. Two follow-ups against the v0.4.0 setup flow surfaced
|
|
83
|
+
during the dogfood validation:
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **`setup` no longer lists the personalization repo under
|
|
88
|
+
`repos:`.** When the operator's personalization repo lives under
|
|
89
|
+
one of the configured owners (e.g. `alice/dotclaude` while `alice`
|
|
90
|
+
is also an enumerated owner), it was being added to the dev
|
|
91
|
+
pipeline's monitored set — the poller would have polled it for
|
|
92
|
+
issues and worktree-cloned it. The repo is the cross-machine sync
|
|
93
|
+
target, not a project; setup now drops it from the enumerated list
|
|
94
|
+
before generating the YAML.
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- **Auto-wire detected skills on `setup`.** When the personalization
|
|
99
|
+
repo already contains `global/skills/<name>/` directories (e.g.
|
|
100
|
+
from a prior `personalization push` on another machine), setup now
|
|
101
|
+
pre-clones the repo, scans for skill subdirectories, and adds one
|
|
102
|
+
`paths:` entry per skill to the generated `personalization.paths`
|
|
103
|
+
block. Operators don't have to hand-edit the config to wire each
|
|
104
|
+
skill back on a new machine. Pass `--no-wire-skills` to opt out.
|
|
105
|
+
Hidden directories and stray top-level files are ignored — only
|
|
106
|
+
real skill packages count.
|
|
107
|
+
|
|
10
108
|
## [0.4.0] - 2026-05-08
|
|
11
109
|
|
|
12
110
|
Minor release. One new feature plus one schema simplification:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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/
|
|
@@ -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=
|
|
626
|
+
transport=transport,
|
|
604
627
|
contexts_dir=config.paths.contexts,
|
|
605
628
|
)
|
|
606
629
|
|
|
@@ -1591,6 +1614,7 @@ def poller_start(
|
|
|
1591
1614
|
|
|
1592
1615
|
try:
|
|
1593
1616
|
if pipeline_name == "secops":
|
|
1617
|
+
sweep_repo_cfg = repo_cfg_by_name.get(repo)
|
|
1594
1618
|
result = await resume_secops_from_pending(
|
|
1595
1619
|
session_id=session_id,
|
|
1596
1620
|
repo=repo,
|
|
@@ -1602,6 +1626,11 @@ def poller_start(
|
|
|
1602
1626
|
state_db=state_db,
|
|
1603
1627
|
transport=sweeper_transport,
|
|
1604
1628
|
contexts_dir=config.paths.contexts,
|
|
1629
|
+
automation=(
|
|
1630
|
+
sweep_repo_cfg.automation
|
|
1631
|
+
if sweep_repo_cfg is not None
|
|
1632
|
+
else None
|
|
1633
|
+
),
|
|
1605
1634
|
)
|
|
1606
1635
|
elif pipeline_name == "dev":
|
|
1607
1636
|
# Dev resume needs the repo's branch template
|
|
@@ -2252,6 +2281,15 @@ def setup(
|
|
|
2252
2281
|
"--no-personalization",
|
|
2253
2282
|
help="Skip the personalization prompt entirely (non-interactive mode).",
|
|
2254
2283
|
),
|
|
2284
|
+
wire_skills: bool = typer.Option(
|
|
2285
|
+
True,
|
|
2286
|
+
"--wire-skills/--no-wire-skills",
|
|
2287
|
+
help=(
|
|
2288
|
+
"When the personalization repo already contains "
|
|
2289
|
+
"global/skills/<name>/ directories, add a paths: entry per "
|
|
2290
|
+
"skill so they sync to ~/.claude/skills/<name>/. Default: on."
|
|
2291
|
+
),
|
|
2292
|
+
),
|
|
2255
2293
|
install_daemons: bool = typer.Option(
|
|
2256
2294
|
False,
|
|
2257
2295
|
"--install-daemons",
|
|
@@ -2412,6 +2450,7 @@ def setup(
|
|
|
2412
2450
|
telegram_chat_id=telegram_chat_id,
|
|
2413
2451
|
telegram_token=token,
|
|
2414
2452
|
personalization_repo=chosen_personalization,
|
|
2453
|
+
wire_skills=wire_skills,
|
|
2415
2454
|
install_daemons=chosen_install_daemons,
|
|
2416
2455
|
skip_archived=skip_archived,
|
|
2417
2456
|
skip_forks=skip_forks,
|
|
@@ -21,6 +21,8 @@ from ctrlrelay.transports.base import Transport
|
|
|
21
21
|
|
|
22
22
|
_logger = get_logger("pipeline.secops")
|
|
23
23
|
|
|
24
|
+
DEFAULT_MAX_BLOCKED_ROUNDS = 5
|
|
25
|
+
|
|
24
26
|
|
|
25
27
|
def _question_for_persist(session_id: str, result: PipelineResult) -> str:
|
|
26
28
|
"""Return a non-empty question string for pending_resumes storage.
|
|
@@ -58,6 +60,7 @@ class SecopsPipeline:
|
|
|
58
60
|
ctx.repo,
|
|
59
61
|
session_id=ctx.session_id,
|
|
60
62
|
state_file=ctx.state_file,
|
|
63
|
+
automation=ctx.extra.get("automation"),
|
|
61
64
|
)
|
|
62
65
|
|
|
63
66
|
result = await self._spawn(ctx, prompt, resume=False)
|
|
@@ -123,21 +126,118 @@ class SecopsPipeline:
|
|
|
123
126
|
repo: str,
|
|
124
127
|
session_id: str = "",
|
|
125
128
|
state_file: Path | None = None,
|
|
129
|
+
automation: Any = None,
|
|
126
130
|
) -> str:
|
|
127
|
-
"""Build the secops prompt.
|
|
131
|
+
"""Build the secops prompt.
|
|
132
|
+
|
|
133
|
+
``automation`` is an optional ``AutomationConfig`` carrying the
|
|
134
|
+
per-repo Dependabot policy (auto/ask/never per severity tier).
|
|
135
|
+
When provided, the prompt explicitly tells the agent which tiers
|
|
136
|
+
auto-merge vs ask vs never-merge. When ``None``, falls back to
|
|
137
|
+
the default policy (patch=auto, minor=ask, never-major) so
|
|
138
|
+
existing callers and the schema defaults stay coherent."""
|
|
128
139
|
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
129
140
|
|
|
141
|
+
# Translate per-repo policy into prompt directives. Use defaults
|
|
142
|
+
# from AutomationConfig if no policy was passed.
|
|
143
|
+
patch_pol = (
|
|
144
|
+
automation.dependabot_patch.value
|
|
145
|
+
if automation is not None and hasattr(automation, "dependabot_patch")
|
|
146
|
+
else "auto"
|
|
147
|
+
)
|
|
148
|
+
minor_pol = (
|
|
149
|
+
automation.dependabot_minor.value
|
|
150
|
+
if automation is not None and hasattr(automation, "dependabot_minor")
|
|
151
|
+
else "ask"
|
|
152
|
+
)
|
|
153
|
+
major_pol = (
|
|
154
|
+
automation.dependabot_major.value
|
|
155
|
+
if automation is not None and hasattr(automation, "dependabot_major")
|
|
156
|
+
else "never"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _policy_directive(tier: str, policy: str) -> str:
|
|
160
|
+
if policy == "auto":
|
|
161
|
+
return (
|
|
162
|
+
f" - {tier}: AUTO-MERGE with passing CI. Squash + "
|
|
163
|
+
"delete branch."
|
|
164
|
+
)
|
|
165
|
+
if policy == "ask":
|
|
166
|
+
return (
|
|
167
|
+
f" - {tier}: ASK — signal BLOCKED with a one-line "
|
|
168
|
+
f"summary so the operator decides."
|
|
169
|
+
)
|
|
170
|
+
return (
|
|
171
|
+
f" - {tier}: NEVER — do not merge. Leave the PR open "
|
|
172
|
+
"for manual review."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
dependabot_policy_block = "\n".join(
|
|
176
|
+
[
|
|
177
|
+
_policy_directive("patch updates", patch_pol),
|
|
178
|
+
_policy_directive("minor updates", minor_pol),
|
|
179
|
+
_policy_directive("major updates", major_pol),
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
|
|
130
183
|
return f"""Execute security operations for repository {repo}.
|
|
131
184
|
|
|
132
185
|
1. Check Dependabot alerts:
|
|
133
186
|
`gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
|
|
134
|
-
2. Check security PRs:
|
|
187
|
+
2. Check Dependabot-authored security PRs:
|
|
135
188
|
`gh pr list --repo {repo} --author "app/dependabot" --json number,title`
|
|
136
|
-
3.
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
189
|
+
3. Determine the trusted operator's GitHub login (the identity the
|
|
190
|
+
agent itself runs as). The auto-merge carve-out below applies ONLY
|
|
191
|
+
to PRs authored by this exact login — never to other contributors,
|
|
192
|
+
apps, or bots even if their PR looks safe:
|
|
193
|
+
`OPERATOR=$(gh api user --jq '.login')`
|
|
194
|
+
4. Check open PRs authored by the trusted operator that enable
|
|
195
|
+
Dependabot itself. These are prerequisites for future Dependabot
|
|
196
|
+
work and block the security pipeline if left open:
|
|
197
|
+
`gh pr list --repo {repo} --state open --author "$OPERATOR" --json number,title,files`
|
|
198
|
+
For each, inspect files: `gh pr view <NUM> --repo {repo} --json files`
|
|
199
|
+
5. For each alert or PR:
|
|
200
|
+
- **Dependabot PRs**: classify the update as patch/minor/major and
|
|
201
|
+
apply the per-repo policy below. If the severity is unclear, signal
|
|
202
|
+
BLOCKED to ask for guidance.
|
|
203
|
+
|
|
204
|
+
{dependabot_policy_block}
|
|
205
|
+
- **PR authored by `$OPERATOR` touching ONLY `.github/dependabot.yml`**
|
|
206
|
+
(additive ecosystem entries, no other files changed): MAYBE
|
|
207
|
+
auto-merge — but only after diff validation. These are the
|
|
208
|
+
"enable Dependabot" prerequisites the operator opened.
|
|
209
|
+
|
|
210
|
+
Required validation BEFORE merging:
|
|
211
|
+
a. **Author check**: confirm `author.login` exactly equals
|
|
212
|
+
`$OPERATOR`. Collaborators, GitHub apps, or external
|
|
213
|
+
contributors can craft a `dependabot.yml`-only PR to slip
|
|
214
|
+
past review — those go to BLOCKED, not auto-merge.
|
|
215
|
+
b. **Diff check**: pull the diff and confirm it is PURELY
|
|
216
|
+
ADDITIVE — no `-` lines (deletions or modifications of
|
|
217
|
+
existing stanzas), only `+` lines that introduce new
|
|
218
|
+
`package-ecosystem` blocks. Run:
|
|
219
|
+
`gh pr diff <NUM> --repo {repo}`
|
|
220
|
+
If any line in the diff begins with `-` (other than `---`
|
|
221
|
+
file headers), the PR modifies or removes existing config:
|
|
222
|
+
signal BLOCKED with a one-line summary like "operator
|
|
223
|
+
dependabot.yml PR #N modifies existing config — needs
|
|
224
|
+
review". Do NOT auto-merge.
|
|
225
|
+
c. **CI check**: confirm all status checks pass (matches the
|
|
226
|
+
Dependabot PR rule above). Run:
|
|
227
|
+
`gh pr checks <NUM> --repo {repo}`
|
|
228
|
+
Any failing or pending check -> BLOCKED. A PR that adds
|
|
229
|
+
invalid dependabot YAML can pass author+diff but fail
|
|
230
|
+
repo validation; without this gate the agent could merge
|
|
231
|
+
broken config and break Dependabot for the repo.
|
|
232
|
+
|
|
233
|
+
If (a), (b), AND (c) all pass: merge with squash, delete the
|
|
234
|
+
branch. If any one fails: BLOCKED.
|
|
235
|
+
- **Any other PR from `$OPERATOR`** (touches code, tests, configs
|
|
236
|
+
other than dependabot.yml): signal BLOCKED for operator approval.
|
|
237
|
+
Never auto-merge code changes, even from the trusted operator.
|
|
238
|
+
- **PRs from anyone else** (collaborators, contributors, other bots):
|
|
239
|
+
signal BLOCKED. Never on this path.
|
|
240
|
+
6. Summarize actions taken
|
|
141
241
|
|
|
142
242
|
## Signaling Completion
|
|
143
243
|
|
|
@@ -214,6 +314,7 @@ async def run_secops_all(
|
|
|
214
314
|
state_db: StateDB,
|
|
215
315
|
transport: Transport | None,
|
|
216
316
|
contexts_dir: Path,
|
|
317
|
+
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
217
318
|
) -> list[PipelineResult]:
|
|
218
319
|
"""Run secops pipeline on all configured repos."""
|
|
219
320
|
results = []
|
|
@@ -260,6 +361,7 @@ async def run_secops_all(
|
|
|
260
361
|
worktree_path=worktree_path,
|
|
261
362
|
context_path=context_path,
|
|
262
363
|
state_file=state_file,
|
|
364
|
+
extra={"automation": getattr(repo_config, "automation", None)},
|
|
263
365
|
)
|
|
264
366
|
|
|
265
367
|
state_db.execute(
|
|
@@ -271,6 +373,51 @@ async def run_secops_all(
|
|
|
271
373
|
session_row_inserted = True
|
|
272
374
|
|
|
273
375
|
result = await pipeline.run(ctx)
|
|
376
|
+
|
|
377
|
+
# BLOCKED loop: when the agent ends BLOCKED_NEEDS_INPUT, push
|
|
378
|
+
# the question to the operator via the configured transport
|
|
379
|
+
# (Telegram), wait for an answer, and resume. Mirrors the
|
|
380
|
+
# dev/task pipelines — without this, secops blocked sessions
|
|
381
|
+
# silently land as "blocked" in the DB without ever reaching
|
|
382
|
+
# the operator. transport.ask() failure preserves blocked=True
|
|
383
|
+
# so the persistence-on-blocked branch below still fires and
|
|
384
|
+
# writes a pending_resumes row that a later out-of-band reply
|
|
385
|
+
# could resume via the sweeper.
|
|
386
|
+
rounds = 0
|
|
387
|
+
while (
|
|
388
|
+
result.blocked
|
|
389
|
+
and transport is not None
|
|
390
|
+
and rounds < max_blocked_rounds
|
|
391
|
+
):
|
|
392
|
+
question = _question_for_persist(session_id, result)
|
|
393
|
+
try:
|
|
394
|
+
answer = await transport.ask(
|
|
395
|
+
question,
|
|
396
|
+
session_id=session_id,
|
|
397
|
+
repo=repo,
|
|
398
|
+
)
|
|
399
|
+
except Exception as e:
|
|
400
|
+
log_event(
|
|
401
|
+
_logger,
|
|
402
|
+
"secops.transport.ask_failed",
|
|
403
|
+
session_id=session_id,
|
|
404
|
+
repo=repo,
|
|
405
|
+
error_type=type(e).__name__,
|
|
406
|
+
error=str(e)[:200],
|
|
407
|
+
)
|
|
408
|
+
result = PipelineResult(
|
|
409
|
+
success=False,
|
|
410
|
+
blocked=True,
|
|
411
|
+
session_id=session_id,
|
|
412
|
+
summary=f"Blocked session deferred (transport): {e}",
|
|
413
|
+
error=str(e),
|
|
414
|
+
question=question,
|
|
415
|
+
outputs=result.outputs,
|
|
416
|
+
)
|
|
417
|
+
break
|
|
418
|
+
rounds += 1
|
|
419
|
+
result = await pipeline.resume(ctx, answer)
|
|
420
|
+
|
|
274
421
|
results.append(result)
|
|
275
422
|
|
|
276
423
|
status = "done" if result.success else ("blocked" if result.blocked else "failed")
|
|
@@ -452,6 +599,7 @@ async def resume_secops_from_pending(
|
|
|
452
599
|
state_db: StateDB,
|
|
453
600
|
transport: Transport | None,
|
|
454
601
|
contexts_dir: Path,
|
|
602
|
+
automation: Any = None,
|
|
455
603
|
) -> PipelineResult:
|
|
456
604
|
"""Resume a BLOCKED secops session using an answer that arrived via
|
|
457
605
|
Telegram after the original session had already torn down.
|
|
@@ -497,6 +645,7 @@ async def resume_secops_from_pending(
|
|
|
497
645
|
worktree_path=worktree_path,
|
|
498
646
|
context_path=context_path,
|
|
499
647
|
state_file=state_file,
|
|
648
|
+
extra={"automation": automation},
|
|
500
649
|
)
|
|
501
650
|
|
|
502
651
|
# Flip the session back to running so an observer sees progress,
|
|
@@ -23,11 +23,14 @@ from ctrlrelay.core.config import Config, load_config
|
|
|
23
23
|
__all__ = [
|
|
24
24
|
"SetupOptions",
|
|
25
25
|
"SetupResult",
|
|
26
|
+
"PersonalizationPath",
|
|
26
27
|
"GhAuthError",
|
|
27
28
|
"VALID_TRANSPORTS",
|
|
28
29
|
"DEFAULT_CONFIG_OUT",
|
|
30
|
+
"DEFAULT_PERSONALIZATION_CHECKOUT",
|
|
29
31
|
"detect_owners",
|
|
30
32
|
"list_repos",
|
|
33
|
+
"detect_personalization_skills",
|
|
31
34
|
"build_orchestrator_yaml",
|
|
32
35
|
"clone_repos",
|
|
33
36
|
"run_setup",
|
|
@@ -45,6 +48,11 @@ VALID_TRANSPORTS = ("file_mock", "telegram")
|
|
|
45
48
|
# orphan them, so setup refuses that combo (see ``run_setup``).
|
|
46
49
|
DEFAULT_CONFIG_OUT = Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser()
|
|
47
50
|
|
|
51
|
+
# Mirrors ``Personalization.checkout_path``'s default. Setup pre-clones
|
|
52
|
+
# into this path before generating the YAML so we can scan for skill
|
|
53
|
+
# directories and wire them in the same setup run (see #129 follow-up).
|
|
54
|
+
DEFAULT_PERSONALIZATION_CHECKOUT = Path("~/.ctrlrelay/personalization").expanduser()
|
|
55
|
+
|
|
48
56
|
|
|
49
57
|
class GhAuthError(RuntimeError):
|
|
50
58
|
"""Raised when ``gh auth status`` fails — setup needs an authenticated gh CLI."""
|
|
@@ -72,11 +80,32 @@ class SetupOptions:
|
|
|
72
80
|
telegram_chat_id: int | None = None
|
|
73
81
|
telegram_token: str | None = None # only used when transport == "telegram"
|
|
74
82
|
personalization_repo: str | None = None # e.g. "alice/dotclaude"
|
|
83
|
+
# When True (the default) and ``personalization_repo`` is set,
|
|
84
|
+
# setup pre-clones the personalization repo and scans
|
|
85
|
+
# ``global/skills/<name>/`` for existing skill directories,
|
|
86
|
+
# wiring each as its own ``paths:`` entry. Skills already laid
|
|
87
|
+
# down by the operator on a prior machine then auto-sync to this
|
|
88
|
+
# one without a hand-edit.
|
|
89
|
+
wire_skills: bool = True
|
|
75
90
|
install_daemons: bool = False
|
|
76
91
|
force: bool = False
|
|
77
92
|
skip_clone: bool = False # for tests / dry-run scenarios
|
|
78
93
|
|
|
79
94
|
|
|
95
|
+
@dataclass
|
|
96
|
+
class PersonalizationPath:
|
|
97
|
+
"""One source/target pair for the personalization block.
|
|
98
|
+
|
|
99
|
+
Setup uses this to assemble the ``personalization.paths`` list:
|
|
100
|
+
the always-on ``global/CLAUDE.md`` entry plus any auto-detected
|
|
101
|
+
skills. Kept as a flat dataclass so tests can construct lists
|
|
102
|
+
without touching the YAML output format.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
source: str
|
|
106
|
+
target: str
|
|
107
|
+
|
|
108
|
+
|
|
80
109
|
@dataclass
|
|
81
110
|
class SetupResult:
|
|
82
111
|
"""End-state summary returned by :func:`run_setup`."""
|
|
@@ -168,15 +197,118 @@ def list_repos(
|
|
|
168
197
|
return sorted(result, key=lambda r: r["nameWithOwner"].lower())
|
|
169
198
|
|
|
170
199
|
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# personalization helpers
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _ensure_personalization_clone(
|
|
205
|
+
repo: str, checkout: Path
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Make sure ``checkout`` is a clone of ``github.com:<repo>.git``.
|
|
208
|
+
|
|
209
|
+
Returns True if the clone is present and matches ``repo`` (existing
|
|
210
|
+
or freshly created), False if the remote refused (auth error, repo
|
|
211
|
+
doesn't exist) OR the existing checkout points at a different repo.
|
|
212
|
+
Errors are swallowed so setup proceeds with the default
|
|
213
|
+
CLAUDE.md-only personalization paths — a missing or unrelated
|
|
214
|
+
remote shouldn't abort the whole onboarding flow, but we also must
|
|
215
|
+
not scan an unrelated working tree's skills and bake them into the
|
|
216
|
+
new config (codex review pass 2).
|
|
217
|
+
"""
|
|
218
|
+
if (checkout / ".git").is_dir():
|
|
219
|
+
return _checkout_matches_repo(checkout, repo)
|
|
220
|
+
checkout.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
# Use HTTPS to match ``PersonalizationManager.repo_url`` so the
|
|
222
|
+
# pre-scan works for operators authenticated to GitHub via gh
|
|
223
|
+
# tokens / HTTPS without an SSH key on the machine. Codex review
|
|
224
|
+
# pass 3 caught the SSH/HTTPS mismatch — pre-scan would silently
|
|
225
|
+
# fail, the manager's own init() would later succeed via HTTPS,
|
|
226
|
+
# and the auto-wire feature would be bypassed in a common setup.
|
|
227
|
+
proc = subprocess.run(
|
|
228
|
+
["git", "clone", "--quiet", f"https://github.com/{repo}.git", str(checkout)],
|
|
229
|
+
capture_output=True,
|
|
230
|
+
text=True,
|
|
231
|
+
)
|
|
232
|
+
return proc.returncode == 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _checkout_matches_repo(checkout: Path, repo: str) -> bool:
|
|
236
|
+
"""Return True iff the existing ``checkout``'s origin remote points
|
|
237
|
+
at ``github.com:<repo>``.
|
|
238
|
+
|
|
239
|
+
Accepts the three URL forms git emits for github.com remotes:
|
|
240
|
+
``https://github.com/owner/repo(.git)``,
|
|
241
|
+
``git@github.com:owner/repo(.git)``, and
|
|
242
|
+
``ssh://git@github.com/owner/repo(.git)``. Comparison is
|
|
243
|
+
case-insensitive — GitHub repo names are. Any other host or any
|
|
244
|
+
error reading the remote returns False (treated as "not ours").
|
|
245
|
+
"""
|
|
246
|
+
proc = subprocess.run(
|
|
247
|
+
["git", "-C", str(checkout), "remote", "get-url", "origin"],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
)
|
|
251
|
+
if proc.returncode != 0:
|
|
252
|
+
return False
|
|
253
|
+
url = proc.stdout.strip()
|
|
254
|
+
expected = repo.lower()
|
|
255
|
+
# Strip the optional ``.git`` suffix and reduce each accepted URL
|
|
256
|
+
# form to ``owner/repo`` for the comparison.
|
|
257
|
+
if url.endswith(".git"):
|
|
258
|
+
url = url[: -len(".git")]
|
|
259
|
+
for prefix in (
|
|
260
|
+
"https://github.com/",
|
|
261
|
+
"ssh://git@github.com/",
|
|
262
|
+
"git@github.com:",
|
|
263
|
+
):
|
|
264
|
+
if url.startswith(prefix):
|
|
265
|
+
return url[len(prefix):].lower() == expected
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def detect_personalization_skills(checkout: Path) -> list[PersonalizationPath]:
|
|
270
|
+
"""Return a sorted list of per-skill ``paths:`` entries derived from
|
|
271
|
+
``<checkout>/global/skills/``.
|
|
272
|
+
|
|
273
|
+
Each direct subdirectory becomes one entry mapping
|
|
274
|
+
``global/skills/<name>/`` -> ``~/.claude/skills/<name>/``. Hidden
|
|
275
|
+
dirs (``.foo``) and files at the top level are skipped — only
|
|
276
|
+
actual skill packages count. Returns an empty list when the
|
|
277
|
+
directory is missing.
|
|
278
|
+
"""
|
|
279
|
+
skills_dir = checkout / "global" / "skills"
|
|
280
|
+
if not skills_dir.is_dir():
|
|
281
|
+
return []
|
|
282
|
+
paths: list[PersonalizationPath] = []
|
|
283
|
+
for entry in sorted(skills_dir.iterdir(), key=lambda p: p.name.lower()):
|
|
284
|
+
if not entry.is_dir() or entry.name.startswith("."):
|
|
285
|
+
continue
|
|
286
|
+
paths.append(
|
|
287
|
+
PersonalizationPath(
|
|
288
|
+
source=f"global/skills/{entry.name}/",
|
|
289
|
+
target=f"~/.claude/skills/{entry.name}/",
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return paths
|
|
293
|
+
|
|
294
|
+
|
|
171
295
|
# ---------------------------------------------------------------------------
|
|
172
296
|
# config generation
|
|
173
297
|
|
|
174
298
|
|
|
175
299
|
def build_orchestrator_yaml(
|
|
176
|
-
options: SetupOptions,
|
|
300
|
+
options: SetupOptions,
|
|
301
|
+
repos_by_owner: dict[str, list[dict]],
|
|
302
|
+
personalization_paths: list[PersonalizationPath] | None = None,
|
|
177
303
|
) -> str:
|
|
178
304
|
"""Render the orchestrator.yaml as a string (no pyyaml — we want full
|
|
179
305
|
control over comments and key order).
|
|
306
|
+
|
|
307
|
+
``personalization_paths`` is the full list of ``paths:`` entries to
|
|
308
|
+
emit under the personalization block. When ``None`` and a
|
|
309
|
+
personalization repo is configured, defaults to the always-on
|
|
310
|
+
``global/CLAUDE.md`` mapping. Pass an explicit list to add detected
|
|
311
|
+
skills (see :func:`detect_personalization_skills`).
|
|
180
312
|
"""
|
|
181
313
|
lines: list[str] = []
|
|
182
314
|
a = lines.append
|
|
@@ -222,11 +354,19 @@ def build_orchestrator_yaml(
|
|
|
222
354
|
a(' secops_cron: "0 6 * * *"')
|
|
223
355
|
a("")
|
|
224
356
|
if options.personalization_repo:
|
|
357
|
+
if personalization_paths is None:
|
|
358
|
+
personalization_paths = [
|
|
359
|
+
PersonalizationPath(
|
|
360
|
+
source="global/CLAUDE.md",
|
|
361
|
+
target="~/.claude/CLAUDE.md",
|
|
362
|
+
)
|
|
363
|
+
]
|
|
225
364
|
a("personalization:")
|
|
226
365
|
a(f' repo: "{options.personalization_repo}"')
|
|
227
366
|
a(" paths:")
|
|
228
|
-
|
|
229
|
-
|
|
367
|
+
for entry in personalization_paths:
|
|
368
|
+
a(f' - source: "{_yaml_escape(entry.source)}"')
|
|
369
|
+
a(f' target: "{_yaml_escape(entry.target)}"')
|
|
230
370
|
a("")
|
|
231
371
|
a("# Repos discovered via `gh repo list`. Filters applied:")
|
|
232
372
|
a(
|
|
@@ -345,11 +485,51 @@ def run_setup(options: SetupOptions) -> SetupResult:
|
|
|
345
485
|
|
|
346
486
|
repos_by_owner: dict[str, list[dict]] = {}
|
|
347
487
|
for owner in options.owners:
|
|
348
|
-
|
|
488
|
+
repos = list_repos(
|
|
349
489
|
owner, skip_archived=options.skip_archived, skip_forks=options.skip_forks
|
|
350
490
|
)
|
|
491
|
+
# Drop the personalization repo if it surfaced under one of the
|
|
492
|
+
# configured owners. It's the cross-machine sync target, not a
|
|
493
|
+
# project the dev pipeline should monitor for issues — listing
|
|
494
|
+
# it under ``repos:`` alongside the per-machine personalization
|
|
495
|
+
# checkout would have ctrlrelay polling and worktree-cloning
|
|
496
|
+
# the operator's own dotfiles. Comparison is case-insensitive
|
|
497
|
+
# because GitHub repo names are case-insensitive: ``alice/foo``
|
|
498
|
+
# and ``Alice/Foo`` resolve to the same repo, so an operator
|
|
499
|
+
# passing one casing while ``gh repo list`` returned the other
|
|
500
|
+
# would otherwise dodge the filter (codex review pass 1).
|
|
501
|
+
if options.personalization_repo:
|
|
502
|
+
personalization_norm = options.personalization_repo.lower()
|
|
503
|
+
repos = [
|
|
504
|
+
r for r in repos
|
|
505
|
+
if r["nameWithOwner"].lower() != personalization_norm
|
|
506
|
+
]
|
|
507
|
+
repos_by_owner[owner] = repos
|
|
508
|
+
|
|
509
|
+
# Build the personalization paths list BEFORE writing the YAML so
|
|
510
|
+
# auto-detected skills end up baked into the file the operator
|
|
511
|
+
# eventually edits. Pre-clone the personalization repo if needed
|
|
512
|
+
# (it might already exist from a prior setup run on this machine).
|
|
513
|
+
personalization_paths: list[PersonalizationPath] | None = None
|
|
514
|
+
if options.personalization_repo:
|
|
515
|
+
personalization_paths = [
|
|
516
|
+
PersonalizationPath(
|
|
517
|
+
source="global/CLAUDE.md",
|
|
518
|
+
target="~/.claude/CLAUDE.md",
|
|
519
|
+
)
|
|
520
|
+
]
|
|
521
|
+
if options.wire_skills:
|
|
522
|
+
cloned_for_scan = _ensure_personalization_clone(
|
|
523
|
+
options.personalization_repo, DEFAULT_PERSONALIZATION_CHECKOUT
|
|
524
|
+
)
|
|
525
|
+
if cloned_for_scan:
|
|
526
|
+
personalization_paths.extend(
|
|
527
|
+
detect_personalization_skills(DEFAULT_PERSONALIZATION_CHECKOUT)
|
|
528
|
+
)
|
|
351
529
|
|
|
352
|
-
yaml_text = build_orchestrator_yaml(
|
|
530
|
+
yaml_text = build_orchestrator_yaml(
|
|
531
|
+
options, repos_by_owner, personalization_paths=personalization_paths
|
|
532
|
+
)
|
|
353
533
|
options.config_out.parent.mkdir(parents=True, exist_ok=True)
|
|
354
534
|
options.config_out.write_text(yaml_text)
|
|
355
535
|
|