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.
Files changed (112) hide show
  1. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/CHANGELOG.md +98 -0
  2. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/pyproject.toml +1 -1
  4. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/cli.py +40 -1
  5. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/secops.py +156 -7
  6. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/setup.py +185 -5
  7. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/socket_client.py +11 -0
  8. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_secops_pipeline.py +392 -0
  9. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_setup.py +280 -0
  10. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_transport.py +97 -0
  11. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  12. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  13. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/dependabot.yml +0 -0
  15. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/build.yml +0 -0
  16. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/cla.yml +0 -0
  17. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/pages.yml +0 -0
  18. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/publish.yml +0 -0
  19. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.github/workflows/test.yml +0 -0
  20. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/.gitignore +0 -0
  21. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  22. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/CONTRIBUTING.md +0 -0
  23. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/LICENSE +0 -0
  24. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/README.md +0 -0
  25. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/SECURITY.md +0 -0
  26. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/config/orchestrator.yaml.example +0 -0
  27. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/Gemfile +0 -0
  28. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/_config.yml +0 -0
  29. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/architecture.md +0 -0
  30. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/bridge.md +0 -0
  31. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/cli.md +0 -0
  32. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/configuration.md +0 -0
  33. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/development.md +0 -0
  34. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/feedback-loop.md +0 -0
  35. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/getting-started.md +0 -0
  36. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/index.md +0 -0
  37. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/operations.md +0 -0
  38. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/docs/personalization.md +0 -0
  39. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/__init__.py +0 -0
  40. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  41. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  42. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  43. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/server.py +0 -0
  44. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  45. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/__init__.py +0 -0
  46. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/audit.py +0 -0
  47. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  48. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/config.py +0 -0
  49. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  50. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/github.py +0 -0
  51. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/obs.py +0 -0
  52. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/poller.py +0 -0
  53. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  54. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  55. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/scheduler.py +0 -0
  56. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/state.py +0 -0
  57. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/core/worktree.py +0 -0
  58. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  59. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/client.py +0 -0
  60. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/install.py +0 -0
  61. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  62. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/manager.py +0 -0
  63. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/paths.py +0 -0
  64. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  65. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/base.py +0 -0
  66. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  67. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  68. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/task.py +0 -0
  69. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/__init__.py +0 -0
  70. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  71. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  72. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  73. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  74. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  75. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  76. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  77. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/__init__.py +0 -0
  78. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/base.py +0 -0
  79. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  80. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/__init__.py +0 -0
  81. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/conftest.py +0 -0
  82. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_audit.py +0 -0
  83. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_bridge_protocol.py +0 -0
  84. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_bridge_server.py +0 -0
  85. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_checkpoint.py +0 -0
  86. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_ci_wait.py +0 -0
  87. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_dev.py +0 -0
  88. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_repos.py +0 -0
  89. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_secops.py +0 -0
  90. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_start.py +0 -0
  91. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_cli_version.py +0 -0
  92. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_config.py +0 -0
  93. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dashboard_client.py +0 -0
  94. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dev_integration.py +0 -0
  95. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dev_pipeline.py +0 -0
  96. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_dispatcher.py +0 -0
  97. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_docs_site.py +0 -0
  98. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_github.py +0 -0
  99. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_install.py +0 -0
  100. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_obs.py +0 -0
  101. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_personalization.py +0 -0
  102. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pipeline_base.py +0 -0
  103. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_poller.py +0 -0
  104. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_post_merge.py +0 -0
  105. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pr_verifier.py +0 -0
  106. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_pr_watcher.py +0 -0
  107. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_scheduler.py +0 -0
  108. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_secops_integration.py +0 -0
  109. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_state.py +0 -0
  110. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_task_pipeline.py +0 -0
  111. {ctrlrelay-0.4.0 → ctrlrelay-0.5.0}/tests/test_telegram_handler.py +0 -0
  112. {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.4.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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.4.0"
3
+ version = "0.5.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
 
@@ -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. For each alert or PR:
137
- - Review the severity and impact
138
- - If patch/minor update with passing CI, merge the PR
139
- - If major or unclear, signal BLOCKED to ask for guidance
140
- 4. Summarize actions taken
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, repos_by_owner: dict[str, list[dict]]
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
- a(' - source: "global/CLAUDE.md"')
229
- a(' target: "~/.claude/CLAUDE.md"')
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
- repos_by_owner[owner] = list_repos(
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(options, repos_by_owner)
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