aidevops 3.13.0 → 3.13.2

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.
@@ -0,0 +1,1024 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
+ # =============================================================================
5
+ # Schedulers Platform Sub-Library -- Platform scheduler setup for contribution
6
+ # watch, complexity scan, pulse merge routine, profile README, OAuth token
7
+ # refresh, OpenCode DB maintenance, repo sync, repo health, and peer
8
+ # productivity monitor.
9
+ # =============================================================================
10
+ # This sub-library is sourced by setup-modules/schedulers.sh (the orchestrator).
11
+ # It covers:
12
+ # - Contribution watch (t1554): passive monitoring of external issues/PRs
13
+ # - Complexity scan (t2903): decoupled weekly complexity scan
14
+ # - Pulse merge routine (t2862, GH#20919): fast 120s merge pass
15
+ # - Draft responses (t1555): private repo + local draft storage
16
+ # - Profile README: auto-create and scheduled update
17
+ # - OAuth token refresh: launchd/systemd/cron/schtasks
18
+ # - OpenCode DB maintenance (r913, t2183): weekly checkpoint/vacuum
19
+ # - Repo sync: daily fast-forward pull
20
+ # - Repo aidevops health (r914): daily drift keeper
21
+ # - Peer productivity monitor (t2932): adaptive cross-runner coordination
22
+ #
23
+ # Usage: source "${SCRIPT_DIR}/schedulers-platform.sh"
24
+ #
25
+ # Dependencies:
26
+ # - shared-constants.sh (print_info, print_warning, print_error)
27
+ # - schedulers-linux.sh (_install_scheduler_linux, _uninstall_scheduler)
28
+ # - schedulers-pulse.sh (_resolve_modern_bash)
29
+ #
30
+ # Part of aidevops framework: https://aidevops.sh
31
+
32
+ # Apply strict mode only when executed directly (not when sourced)
33
+ [[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
34
+
35
+ # Include guard
36
+ [[ -n "${_SCHEDULERS_PLATFORM_LIB_LOADED:-}" ]] && return 0
37
+ _SCHEDULERS_PLATFORM_LIB_LOADED=1
38
+
39
+ # SCRIPT_DIR fallback — needed when sourced from test harnesses that don't set it.
40
+ if [[ -z "${SCRIPT_DIR:-}" ]]; then
41
+ _sched_platform_lib_path="${BASH_SOURCE[0]%/*}"
42
+ [[ "$_sched_platform_lib_path" == "${BASH_SOURCE[0]}" ]] && _sched_platform_lib_path="."
43
+ SCRIPT_DIR="$(cd "$_sched_platform_lib_path" && pwd)"
44
+ unset _sched_platform_lib_path
45
+ fi
46
+
47
+ # --- Functions ---
48
+
49
+ # Resolve and validate the log directory from config for contribution watch.
50
+ # Reads paths.log_dir from jsonc config, validates characters, expands tilde.
51
+ # Prints the resolved absolute path. Returns 1 on invalid characters.
52
+ _resolve_cw_log_dir() {
53
+ local _cw_log_dir
54
+ # shellcheck disable=SC2088 # Tilde is intentionally literal here; expanded below via ${/#\~/$HOME}
55
+ if type _jsonc_get &>/dev/null; then
56
+ _cw_log_dir=$(_jsonc_get "paths.log_dir" "~/.aidevops/logs")
57
+ else
58
+ _cw_log_dir="~/.aidevops/logs"
59
+ fi
60
+ # Whitelist: only allow characters safe in shell paths and cron lines.
61
+ # Reject anything outside [A-Za-z0-9_./ ~-] (tilde allowed before expansion).
62
+ # Store regex in variable — bash [[ =~ ]] requires unquoted RHS for regex,
63
+ # and a variable avoids quoting issues with special chars in the pattern.
64
+ local _cw_log_dir_re='^[A-Za-z0-9_./ ~-]+$'
65
+ if ! [[ "$_cw_log_dir" =~ $_cw_log_dir_re ]]; then
66
+ # Redirect to stderr so $() captures only the path result
67
+ print_error "Invalid characters in paths.log_dir (only [A-Za-z0-9_./ ~-] allowed): $_cw_log_dir" >&2
68
+ return 1
69
+ fi
70
+ _cw_log_dir="${_cw_log_dir/#\~/$HOME}"
71
+ printf '%s' "$_cw_log_dir"
72
+ return 0
73
+ }
74
+
75
+ # Install contribution watch via launchd (macOS).
76
+ # Args: $1=label, $2=script path, $3=log dir
77
+ _install_cw_launchd() {
78
+ local cw_label="$1"
79
+ local cw_script="$2"
80
+ local _cw_log_dir="$3"
81
+ local cw_plist="$HOME/Library/LaunchAgents/${cw_label}.plist"
82
+
83
+ local _xml_cw_script _xml_cw_home _xml_cw_log_dir
84
+ _xml_cw_script=$(_xml_escape "$cw_script")
85
+ _xml_cw_home=$(_xml_escape "$HOME")
86
+ _xml_cw_log_dir=$(_xml_escape "$_cw_log_dir")
87
+
88
+ local cw_plist_content
89
+ cw_plist_content=$(
90
+ cat <<CW_PLIST
91
+ <?xml version="1.0" encoding="UTF-8"?>
92
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
93
+ <plist version="1.0">
94
+ <dict>
95
+ <key>Label</key>
96
+ <string>${cw_label}</string>
97
+ <key>ProgramArguments</key>
98
+ <array>
99
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
100
+ <string>${_xml_cw_script}</string>
101
+ <string>scan</string>
102
+ </array>
103
+ <key>StartInterval</key>
104
+ <integer>3600</integer>
105
+ <key>StandardOutPath</key>
106
+ <string>${_xml_cw_log_dir}/contribution-watch.log</string>
107
+ <key>StandardErrorPath</key>
108
+ <string>${_xml_cw_log_dir}/contribution-watch.log</string>
109
+ <key>EnvironmentVariables</key>
110
+ <dict>
111
+ <key>PATH</key>
112
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
113
+ <key>HOME</key>
114
+ <string>${_xml_cw_home}</string>
115
+ </dict>
116
+ <key>RunAtLoad</key>
117
+ <false/>
118
+ <key>KeepAlive</key>
119
+ <false/>
120
+ <key>ProcessType</key>
121
+ <string>Background</string>
122
+ <key>LowPriorityBackgroundIO</key>
123
+ <true/>
124
+ <key>Nice</key>
125
+ <integer>10</integer>
126
+ </dict>
127
+ </plist>
128
+ CW_PLIST
129
+ )
130
+
131
+ if _launchd_install_if_changed "$cw_label" "$cw_plist" "$cw_plist_content"; then
132
+ print_info "Contribution watch enabled (launchd, hourly scan)"
133
+ else
134
+ print_warning "Failed to load contribution watch LaunchAgent"
135
+ fi
136
+ return 0
137
+ }
138
+
139
+ # Install contribution watch via systemd or cron (Linux).
140
+ # Args: $1=script path, $2=log dir
141
+ _install_cw_linux() {
142
+ local cw_script="$1"
143
+ local _cw_log_dir="$2"
144
+ local cw_systemd="aidevops-contribution-watch"
145
+ _install_scheduler_linux \
146
+ "$cw_systemd" \
147
+ "aidevops: contribution-watch" \
148
+ "$CRON_HOURLY" \
149
+ "\"${cw_script}\" scan" \
150
+ "3600" \
151
+ "${_cw_log_dir}/contribution-watch.log" \
152
+ "" \
153
+ "Contribution watch enabled (hourly scan)" \
154
+ "Failed to install contribution watch scheduler" \
155
+ "false" \
156
+ "true"
157
+ return 0
158
+ }
159
+
160
+ # Setup contribution watch — monitors external issues/PRs for new activity (t1554).
161
+ # Auto-seeds on first run (discovers authored/commented issues/PRs), then installs
162
+ # a launchd/systemd/cron job to scan periodically. Requires gh CLI authenticated.
163
+ # No consent needed — this is passive monitoring (read-only notifications API),
164
+ # not autonomous action. Comment bodies are never processed by LLM in automated context.
165
+ # Respects config: aidevops config set orchestration.contribution_watch false
166
+ setup_contribution_watch() {
167
+ local cw_script="$HOME/.aidevops/agents/scripts/contribution-watch-helper.sh"
168
+ local cw_label="sh.aidevops.contribution-watch"
169
+ local cw_state="$HOME/.aidevops/cache/contribution-watch.json"
170
+ if ! [[ -x "$cw_script" ]] || ! is_feature_enabled orchestration.contribution_watch 2>/dev/null || ! command -v gh &>/dev/null || ! gh auth status &>/dev/null 2>&1; then
171
+ return 0
172
+ fi
173
+
174
+ # Resolve and validate log directory
175
+ local _cw_log_dir
176
+ _cw_log_dir=$(_resolve_cw_log_dir) || return 1
177
+ mkdir -p "$HOME/.aidevops/cache" "$_cw_log_dir"
178
+
179
+ # Auto-seed on first run (populates state file with existing contributions)
180
+ if [[ ! -f "$cw_state" ]]; then
181
+ print_info "Discovering external contributions for contribution watch..."
182
+ if bash "$cw_script" seed >/dev/null 2>&1; then
183
+ print_info "Contribution watch seeded (external issues/PRs discovered)"
184
+ else
185
+ print_warning "Contribution watch seed failed (non-fatal, will retry on next run)"
186
+ fi
187
+ fi
188
+
189
+ # Install/update scheduled scanner
190
+ if [[ "$(uname -s)" == "Darwin" ]]; then
191
+ _install_cw_launchd "$cw_label" "$cw_script" "$_cw_log_dir"
192
+ else
193
+ _install_cw_linux "$cw_script" "$_cw_log_dir"
194
+ fi
195
+ return 0
196
+ }
197
+
198
+ # Install complexity scan via launchd (macOS).
199
+ # Args: $1=label, $2=script path, $3=log dir
200
+ # (t2903) Extracted from pulse dispatch preflight — independent schedule so
201
+ # the 200-470s scan never starves dispatch or downstream scanners.
202
+ _install_complexity_scan_launchd() {
203
+ local cs_label="$1"
204
+ local cs_script="$2"
205
+ local _cs_log_dir="$3"
206
+ local cs_plist="$HOME/Library/LaunchAgents/${cs_label}.plist"
207
+
208
+ local _xml_cs_script _xml_cs_home _xml_cs_log_dir
209
+ _xml_cs_script=$(_xml_escape "$cs_script")
210
+ _xml_cs_home=$(_xml_escape "$HOME")
211
+ _xml_cs_log_dir=$(_xml_escape "$_cs_log_dir")
212
+
213
+ local cs_plist_content
214
+ cs_plist_content=$(
215
+ cat <<CS_PLIST
216
+ <?xml version="1.0" encoding="UTF-8"?>
217
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
218
+ <plist version="1.0">
219
+ <dict>
220
+ <key>Label</key>
221
+ <string>${cs_label}</string>
222
+ <key>ProgramArguments</key>
223
+ <array>
224
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
225
+ <string>${_xml_cs_script}</string>
226
+ <string>run</string>
227
+ </array>
228
+ <key>StartInterval</key>
229
+ <integer>3600</integer>
230
+ <key>StandardOutPath</key>
231
+ <string>${_xml_cs_log_dir}/complexity-scan-runner.log</string>
232
+ <key>StandardErrorPath</key>
233
+ <string>${_xml_cs_log_dir}/complexity-scan-runner.log</string>
234
+ <key>EnvironmentVariables</key>
235
+ <dict>
236
+ <key>PATH</key>
237
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
238
+ <key>HOME</key>
239
+ <string>${_xml_cs_home}</string>
240
+ </dict>
241
+ <key>RunAtLoad</key>
242
+ <true/>
243
+ <key>KeepAlive</key>
244
+ <false/>
245
+ <key>ProcessType</key>
246
+ <string>Background</string>
247
+ <key>LowPriorityBackgroundIO</key>
248
+ <true/>
249
+ <key>Nice</key>
250
+ <integer>10</integer>
251
+ </dict>
252
+ </plist>
253
+ CS_PLIST
254
+ )
255
+
256
+ if _launchd_install_if_changed "$cs_label" "$cs_plist" "$cs_plist_content"; then
257
+ print_info "Complexity scan enabled (launchd, hourly run)"
258
+ else
259
+ print_warning "Failed to load complexity scan LaunchAgent"
260
+ fi
261
+ return 0
262
+ }
263
+
264
+ # Install complexity scan via systemd or cron (Linux).
265
+ # Args: $1=script path, $2=log dir
266
+ _install_complexity_scan_linux() {
267
+ local cs_script="$1"
268
+ local _cs_log_dir="$2"
269
+ local cs_systemd="aidevops-complexity-scan"
270
+ _install_scheduler_linux \
271
+ "$cs_systemd" \
272
+ "aidevops: complexity-scan" \
273
+ "$CRON_HOURLY" \
274
+ "\"${cs_script}\" run" \
275
+ "3600" \
276
+ "${_cs_log_dir}/complexity-scan-runner.log" \
277
+ "" \
278
+ "Complexity scan enabled (hourly run)" \
279
+ "Failed to install complexity scan scheduler" \
280
+ "true" \
281
+ "true"
282
+ return 0
283
+ }
284
+
285
+ # Setup complexity scan (t2903) — extracts the weekly complexity scan from
286
+ # pulse dispatch preflight into its own launchd/cron schedule. The scan was
287
+ # observed consuming 200-470s per pulse cycle (26%+ of the 1800s pulse stale
288
+ # ceiling), starving downstream scanners. Promoting it to its own schedule
289
+ # decouples it from dispatch entirely. The runner reuses run_weekly_complexity_scan
290
+ # from pulse-simplification.sh, which has internal 15-min cadence gating
291
+ # (COMPLEXITY_SCAN_INTERVAL=900) so hourly launchd ticks are always safe.
292
+ setup_complexity_scan() {
293
+ local cs_script="$HOME/.aidevops/agents/scripts/complexity-scan-runner.sh"
294
+ local cs_label="sh.aidevops.complexity-scan"
295
+ if ! [[ -x "$cs_script" ]]; then
296
+ return 0
297
+ fi
298
+
299
+ # Reuse contribution-watch's log-dir resolver (same logic, same config key).
300
+ local _cs_log_dir
301
+ _cs_log_dir=$(_resolve_cw_log_dir) || return 1
302
+ mkdir -p "$_cs_log_dir"
303
+
304
+ # Install/update scheduled runner
305
+ if [[ "$(uname -s)" == "Darwin" ]]; then
306
+ _install_complexity_scan_launchd "$cs_label" "$cs_script" "$_cs_log_dir"
307
+ else
308
+ _install_complexity_scan_linux "$cs_script" "$_cs_log_dir"
309
+ fi
310
+ return 0
311
+ }
312
+
313
+ # Install pulse-merge-routine launchd plist (macOS).
314
+ # Args: $1=label $2=script $3=log_dir
315
+ _install_pulse_merge_routine_launchd() {
316
+ local pmr_label="$1"
317
+ local pmr_script="$2"
318
+ local _pmr_log_dir="$3"
319
+ local pmr_plist="$HOME/Library/LaunchAgents/${pmr_label}.plist"
320
+
321
+ local _xml_pmr_script _xml_pmr_home _xml_pmr_log_dir
322
+ _xml_pmr_script=$(_xml_escape "$pmr_script")
323
+ _xml_pmr_home=$(_xml_escape "$HOME")
324
+ _xml_pmr_log_dir=$(_xml_escape "$_pmr_log_dir")
325
+
326
+ local pmr_plist_content
327
+ pmr_plist_content=$(
328
+ cat <<PMR_PLIST
329
+ <?xml version="1.0" encoding="UTF-8"?>
330
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
331
+ <plist version="1.0">
332
+ <dict>
333
+ <key>Label</key>
334
+ <string>${pmr_label}</string>
335
+ <key>ProgramArguments</key>
336
+ <array>
337
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
338
+ <string>${_xml_pmr_script}</string>
339
+ <string>run</string>
340
+ </array>
341
+ <key>StartInterval</key>
342
+ <integer>120</integer>
343
+ <key>StandardOutPath</key>
344
+ <string>${_xml_pmr_log_dir}/pulse-merge-routine.log</string>
345
+ <key>StandardErrorPath</key>
346
+ <string>${_xml_pmr_log_dir}/pulse-merge-routine.log</string>
347
+ <key>EnvironmentVariables</key>
348
+ <dict>
349
+ <key>PATH</key>
350
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
351
+ <key>HOME</key>
352
+ <string>${_xml_pmr_home}</string>
353
+ </dict>
354
+ <key>RunAtLoad</key>
355
+ <true/>
356
+ <key>KeepAlive</key>
357
+ <false/>
358
+ <key>ProcessType</key>
359
+ <string>Background</string>
360
+ <key>LowPriorityBackgroundIO</key>
361
+ <true/>
362
+ <key>Nice</key>
363
+ <integer>10</integer>
364
+ </dict>
365
+ </plist>
366
+ PMR_PLIST
367
+ )
368
+
369
+ if _launchd_install_if_changed "$pmr_label" "$pmr_plist" "$pmr_plist_content"; then
370
+ print_info "Pulse merge routine enabled (launchd, every 2 min)"
371
+ else
372
+ print_warning "Failed to load pulse merge routine LaunchAgent"
373
+ fi
374
+ return 0
375
+ }
376
+
377
+ # Install pulse-merge-routine via systemd or cron (Linux).
378
+ # Args: $1=script path, $2=log dir
379
+ _install_pulse_merge_routine_linux() {
380
+ local pmr_script="$1"
381
+ local _pmr_log_dir="$2"
382
+ local pmr_systemd="aidevops-pulse-merge-routine"
383
+ _install_scheduler_linux \
384
+ "$pmr_systemd" \
385
+ "aidevops: pulse-merge-routine" \
386
+ "*/2 * * * *" \
387
+ "\"${pmr_script}\" run" \
388
+ "120" \
389
+ "${_pmr_log_dir}/pulse-merge-routine.log" \
390
+ "" \
391
+ "Pulse merge routine enabled (every 2 min)" \
392
+ "Failed to install pulse merge routine scheduler" \
393
+ "true" \
394
+ "true"
395
+ return 0
396
+ }
397
+
398
+ # Setup pulse merge routine (t2862, GH#20919) — runs merge_ready_prs_all_repos()
399
+ # as a fast 120s standalone routine, decoupled from the monolithic pulse cycle.
400
+ # The pulse cycle's preflight stack (60-470s) meant the merge pass ran only ~7
401
+ # times/24h despite ~40+ cycles. This routine ensures green PRs merge within ~3
402
+ # min of CI completion. The in-cycle merge call in pulse-wrapper.sh is kept as
403
+ # defense-in-depth but short-circuits when this routine ran within the last 60s.
404
+ setup_pulse_merge_routine() {
405
+ local pmr_script="$HOME/.aidevops/agents/scripts/pulse-merge-routine.sh"
406
+ local pmr_label="sh.aidevops.pulse-merge-routine"
407
+ if ! [[ -x "$pmr_script" ]]; then
408
+ return 0
409
+ fi
410
+
411
+ # Reuse contribution-watch's log-dir resolver (same logic, same config key).
412
+ local _pmr_log_dir
413
+ _pmr_log_dir=$(_resolve_cw_log_dir) || return 1
414
+ mkdir -p "$_pmr_log_dir"
415
+
416
+ # Install/update scheduled runner
417
+ if [[ "$(uname -s)" == "Darwin" ]]; then
418
+ _install_pulse_merge_routine_launchd "$pmr_label" "$pmr_script" "$_pmr_log_dir"
419
+ else
420
+ _install_pulse_merge_routine_linux "$pmr_script" "$_pmr_log_dir"
421
+ fi
422
+ return 0
423
+ }
424
+
425
+ # Setup draft responses — private repo + local draft storage for reviewing
426
+ # AI-drafted replies to external contributions (t1555).
427
+ # Respects config: aidevops config set orchestration.draft_responses false
428
+ setup_draft_responses() {
429
+ local dr_script="$HOME/.aidevops/agents/scripts/draft-response-helper.sh"
430
+ if [[ -x "$dr_script" ]] && is_feature_enabled orchestration.draft_responses 2>/dev/null && is_feature_enabled orchestration.contribution_watch 2>/dev/null && command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then
431
+ mkdir -p "$HOME/.aidevops/.agent-workspace/draft-responses"
432
+ if bash "$dr_script" init >/dev/null 2>&1; then
433
+ print_info "Draft responses ready (private repo + local drafts)"
434
+ else
435
+ print_warning "Draft responses repo setup failed (non-fatal, local drafts still work)"
436
+ fi
437
+ fi
438
+ return 0
439
+ }
440
+
441
+ # Setup profile README — auto-create repo and seed README if not already set up.
442
+ # Requires gh CLI authenticated. Creates username/username repo, seeds README
443
+ # with stat markers, registers in repos.json with priority: "profile".
444
+ _profile_readme_ready() {
445
+ local pr_script="$1"
446
+ if ! [[ -x "$pr_script" ]]; then
447
+ return 1
448
+ fi
449
+ if ! command -v gh &>/dev/null; then
450
+ return 1
451
+ fi
452
+ if ! gh auth status &>/dev/null; then
453
+ return 1
454
+ fi
455
+ return 0
456
+ }
457
+
458
+ _run_profile_readme_init() {
459
+ local pr_script="$1"
460
+ print_info "Checking GitHub profile README..."
461
+ if bash "$pr_script" init; then
462
+ print_info "Profile README ready."
463
+ else
464
+ print_warning "Profile README setup failed (non-fatal, skipping)"
465
+ fi
466
+ return 0
467
+ }
468
+
469
+ _install_profile_readme_launchd() {
470
+ local pr_label="$1"
471
+ local pr_script="$2"
472
+ local pr_plist="$HOME/Library/LaunchAgents/${pr_label}.plist"
473
+ local _xml_pr_script _xml_pr_home
474
+ _xml_pr_script=$(_xml_escape "$pr_script")
475
+ _xml_pr_home=$(_xml_escape "$HOME")
476
+
477
+ local pr_plist_content
478
+ pr_plist_content=$(
479
+ cat <<PR_PLIST
480
+ <?xml version="1.0" encoding="UTF-8"?>
481
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
482
+ <plist version="1.0">
483
+ <dict>
484
+ <key>Label</key>
485
+ <string>${pr_label}</string>
486
+ <key>ProgramArguments</key>
487
+ <array>
488
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
489
+ <string>${_xml_pr_script}</string>
490
+ <string>update</string>
491
+ </array>
492
+ <key>StartInterval</key>
493
+ <integer>3600</integer>
494
+ <key>StandardOutPath</key>
495
+ <string>${_xml_pr_home}/.aidevops/.agent-workspace/logs/profile-readme-update.log</string>
496
+ <key>StandardErrorPath</key>
497
+ <string>${_xml_pr_home}/.aidevops/.agent-workspace/logs/profile-readme-update.log</string>
498
+ <key>EnvironmentVariables</key>
499
+ <dict>
500
+ <key>PATH</key>
501
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
502
+ <key>HOME</key>
503
+ <string>${_xml_pr_home}</string>
504
+ </dict>
505
+ <key>RunAtLoad</key>
506
+ <false/>
507
+ <key>KeepAlive</key>
508
+ <false/>
509
+ <key>ProcessType</key>
510
+ <string>Background</string>
511
+ <key>LowPriorityBackgroundIO</key>
512
+ <true/>
513
+ <key>Nice</key>
514
+ <integer>10</integer>
515
+ </dict>
516
+ </plist>
517
+ PR_PLIST
518
+ )
519
+
520
+ if _launchd_install_if_changed "$pr_label" "$pr_plist" "$pr_plist_content"; then
521
+ print_info "Profile README update enabled (launchd, hourly)"
522
+ else
523
+ print_warning "Failed to load profile README update LaunchAgent"
524
+ fi
525
+ return 0
526
+ }
527
+
528
+ _install_profile_readme_scheduler() {
529
+ local pr_label="$1"
530
+ local pr_systemd="$2"
531
+ local pr_script="$3"
532
+ local pr_log="$4"
533
+
534
+ if [[ "$(uname -s)" == "Darwin" ]]; then
535
+ _install_profile_readme_launchd "$pr_label" "$pr_script"
536
+ return 0
537
+ fi
538
+
539
+ _install_scheduler_linux \
540
+ "$pr_systemd" \
541
+ "aidevops: profile-readme-update" \
542
+ "$CRON_HOURLY" \
543
+ "\"${pr_script}\" update" \
544
+ "3600" \
545
+ "$pr_log" \
546
+ "" \
547
+ "Profile README update enabled (hourly)" \
548
+ "Failed to install profile README update scheduler" \
549
+ "false" \
550
+ "true"
551
+ return 0
552
+ }
553
+
554
+ setup_profile_readme() {
555
+ local pr_script="$HOME/.aidevops/agents/scripts/profile-readme-helper.sh"
556
+ local pr_label="sh.aidevops.profile-readme-update"
557
+ if ! _profile_readme_ready "$pr_script"; then
558
+ return 0
559
+ fi
560
+
561
+ # Initialize profile repo if not already set up.
562
+ # Always run init — it's idempotent and handles:
563
+ # - Fresh installs (no profile repo)
564
+ # - Missing markers (injects them into existing README)
565
+ # - Diverged history (repo deleted and recreated on GitHub)
566
+ # - Already-initialized repos (returns early with no changes)
567
+ _run_profile_readme_init "$pr_script"
568
+
569
+ # Profile README auto-update scheduled job.
570
+ # Installed whenever gh CLI is available — the update script self-heals
571
+ # (discovers/creates the profile repo on first run via _resolve_profile_repo).
572
+ # macOS: launchd plist (hourly) | Linux: systemd timer or cron (hourly)
573
+ local pr_systemd="aidevops-profile-readme-update"
574
+ local pr_log="$HOME/.aidevops/.agent-workspace/logs/profile-readme-update.log"
575
+ mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
576
+
577
+ _install_profile_readme_scheduler "$pr_label" "$pr_systemd" "$pr_script" "$pr_log"
578
+ return 0
579
+ }
580
+
581
+ # Detect Windows Git Bash / MINGW64 / MSYS2 environment.
582
+ # WSL reports "Linux" from uname -s and uses the cron path — correct behaviour.
583
+ # Returns 0 (true) on Windows Git Bash/MINGW/MSYS/Cygwin, 1 otherwise.
584
+ _is_windows() {
585
+ case "$(uname -s)" in
586
+ MINGW* | MSYS* | CYGWIN*)
587
+ return 0
588
+ ;;
589
+ *)
590
+ return 1
591
+ ;;
592
+ esac
593
+ }
594
+
595
+ # Install OAuth token refresh via Windows Task Scheduler (schtasks).
596
+ # Args: $1=tr_script (Unix path), $2=log_dir (Unix path)
597
+ # Runs every 30 minutes, matching macOS launchd and Linux cron behaviour.
598
+ # Uses bash.exe from Git for Windows to execute the shell script.
599
+ _install_token_refresh_schtasks() {
600
+ local tr_script="$1"
601
+ local log_dir="$2"
602
+ local task_name="aidevops-token-refresh"
603
+
604
+ # Resolve bash.exe — Git for Windows ships it alongside git.exe
605
+ local bash_exe
606
+ bash_exe=$(command -v bash.exe 2>/dev/null || command -v bash 2>/dev/null || echo "bash")
607
+
608
+ # Convert Unix paths to Windows paths for schtasks (requires cygpath from Git Bash)
609
+ local tr_script_win log_dir_win bash_exe_win
610
+ if command -v cygpath &>/dev/null; then
611
+ tr_script_win=$(cygpath -w "$tr_script")
612
+ log_dir_win=$(cygpath -w "$log_dir")
613
+ bash_exe_win=$(cygpath -w "$bash_exe")
614
+ else
615
+ # Fallback: manual conversion (replace /c/ with C:\, forward to backslash)
616
+ tr_script_win=$(echo "$tr_script" | sed 's|^/\([a-zA-Z]\)/|\1:\\|; s|/|\\|g')
617
+ log_dir_win=$(echo "$log_dir" | sed 's|^/\([a-zA-Z]\)/|\1:\\|; s|/|\\|g')
618
+ bash_exe_win="bash.exe"
619
+ fi
620
+
621
+ # Remove existing task (idempotent — ignore error if not present)
622
+ schtasks /Delete /TN "$task_name" /F >/dev/null 2>&1 || true
623
+
624
+ # Create scheduled task: every 30 minutes, run at logon, run whether logged on or not
625
+ # /SC MINUTE /MO 30 = every 30 minutes
626
+ # /RL HIGHEST = run with highest available privileges (needed for token writes)
627
+ # /F = force creation (overwrite if exists)
628
+ # The action runs bash.exe with -c to chain both refresh calls
629
+ local action_cmd
630
+ action_cmd="\"${bash_exe_win}\" -c \"'${tr_script_win}' refresh anthropic >> '${log_dir_win}\\token-refresh.log' 2>&1; '${tr_script_win}' refresh openai >> '${log_dir_win}\\token-refresh.log' 2>&1\""
631
+
632
+ if schtasks /Create \
633
+ /TN "$task_name" \
634
+ /TR "$action_cmd" \
635
+ /SC MINUTE \
636
+ /MO 30 \
637
+ /RL HIGHEST \
638
+ /F \
639
+ >/dev/null 2>&1; then
640
+ print_info "OAuth token refresh enabled (schtasks, every 30 min)"
641
+ # Run immediately to refresh any expired tokens
642
+ schtasks /Run /TN "$task_name" >/dev/null 2>&1 || true
643
+ else
644
+ print_warning "Failed to create token refresh scheduled task. Run manually: schtasks /Create /TN aidevops-token-refresh /TR \"bash '${tr_script_win}' refresh anthropic\" /SC MINUTE /MO 30"
645
+ fi
646
+ return 0
647
+ }
648
+
649
+ # Remove OAuth token refresh Windows scheduled task (uninstall path).
650
+ _uninstall_token_refresh_schtasks() {
651
+ local task_name="aidevops-token-refresh"
652
+ if schtasks /Query /TN "$task_name" >/dev/null 2>&1; then
653
+ schtasks /Delete /TN "$task_name" /F >/dev/null 2>&1 || true
654
+ print_info "OAuth token refresh disabled (schtasks task removed)"
655
+ fi
656
+ return 0
657
+ }
658
+
659
+ # Setup OAuth token refresh scheduled job.
660
+ # Refreshes expired/expiring tokens every 30 min so sessions never hit
661
+ # "invalid x-api-key". Also runs at load to catch tokens that expired
662
+ # while the machine was off.
663
+ # macOS: launchd plist | Linux/WSL: systemd timer or cron | Windows Git Bash: schtasks
664
+ _oauth_token_refresh_ready() {
665
+ local tr_script="$1"
666
+ if ! [[ -x "$tr_script" ]]; then
667
+ return 1
668
+ fi
669
+ if ! [[ -f "$HOME/.aidevops/oauth-pool.json" ]]; then
670
+ return 1
671
+ fi
672
+ return 0
673
+ }
674
+
675
+ _install_token_refresh_launchd() {
676
+ local tr_label="$1"
677
+ local tr_script="$2"
678
+ local tr_plist="$HOME/Library/LaunchAgents/${tr_label}.plist"
679
+ local _xml_tr_script _xml_tr_home
680
+ _xml_tr_script=$(_xml_escape "$tr_script")
681
+ _xml_tr_home=$(_xml_escape "$HOME")
682
+
683
+ local tr_plist_content
684
+ tr_plist_content=$(
685
+ cat <<TR_PLIST
686
+ <?xml version="1.0" encoding="UTF-8"?>
687
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
688
+ <plist version="1.0">
689
+ <dict>
690
+ <key>Label</key>
691
+ <string>${tr_label}</string>
692
+ <key>ProgramArguments</key>
693
+ <array>
694
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
695
+ <string>-c</string>
696
+ <string>&quot;${_xml_tr_script}&quot; refresh anthropic; &quot;${_xml_tr_script}&quot; refresh openai</string>
697
+ </array>
698
+ <key>StartInterval</key>
699
+ <integer>1800</integer>
700
+ <key>StandardOutPath</key>
701
+ <string>${_xml_tr_home}/.aidevops/.agent-workspace/logs/token-refresh.log</string>
702
+ <key>StandardErrorPath</key>
703
+ <string>${_xml_tr_home}/.aidevops/.agent-workspace/logs/token-refresh.log</string>
704
+ <key>EnvironmentVariables</key>
705
+ <dict>
706
+ <key>PATH</key>
707
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
708
+ <key>HOME</key>
709
+ <string>${_xml_tr_home}</string>
710
+ </dict>
711
+ <key>RunAtLoad</key>
712
+ <true/>
713
+ <key>KeepAlive</key>
714
+ <false/>
715
+ <key>ProcessType</key>
716
+ <string>Background</string>
717
+ <key>LowPriorityBackgroundIO</key>
718
+ <true/>
719
+ <key>Nice</key>
720
+ <integer>10</integer>
721
+ </dict>
722
+ </plist>
723
+ TR_PLIST
724
+ )
725
+
726
+ if _launchd_install_if_changed "$tr_label" "$tr_plist" "$tr_plist_content"; then
727
+ print_info "OAuth token refresh enabled (launchd, every 30 min)"
728
+ else
729
+ print_warning "Failed to load token refresh LaunchAgent"
730
+ fi
731
+ return 0
732
+ }
733
+
734
+ setup_oauth_token_refresh() {
735
+ local tr_script="$HOME/.aidevops/agents/scripts/oauth-pool-helper.sh"
736
+ local tr_label="sh.aidevops.token-refresh"
737
+ if ! _oauth_token_refresh_ready "$tr_script"; then
738
+ return 0
739
+ fi
740
+
741
+ local tr_log_dir="$HOME/.aidevops/.agent-workspace/logs"
742
+ mkdir -p "$tr_log_dir"
743
+
744
+ if [[ "$(uname -s)" == "Darwin" ]]; then
745
+ _install_token_refresh_launchd "$tr_label" "$tr_script"
746
+ elif _is_windows; then
747
+ # Windows Git Bash / MINGW64 / MSYS2: use Task Scheduler (schtasks)
748
+ _install_token_refresh_schtasks "$tr_script" "$tr_log_dir"
749
+ else
750
+ # Linux / WSL without systemd: systemd timer or cron fallback
751
+ _install_scheduler_linux \
752
+ "aidevops-token-refresh" \
753
+ "aidevops: token-refresh" \
754
+ "*/30 * * * *" \
755
+ "\"${tr_script}\" refresh anthropic; \"${tr_script}\" refresh openai" \
756
+ "1800" \
757
+ "${tr_log_dir}/token-refresh.log" \
758
+ "" \
759
+ "OAuth token refresh enabled (every 30 min)" \
760
+ "Failed to install token refresh scheduler" \
761
+ "true" \
762
+ "true"
763
+ fi
764
+ return 0
765
+ }
766
+
767
+ # Setup opencode DB maintenance scheduler (r913, t2183).
768
+ # Runs weekly (Sun 04:00 local) to checkpoint/optimize/vacuum opencode.db.
769
+ # The helper self-noops on missing DB, so installing unconditionally is safe —
770
+ # a non-opencode machine wakes up weekly, sees no DB, exits 0 silently.
771
+ #
772
+ # Platform split (mirrors the pattern for token-refresh):
773
+ # macOS — helper owns its plist generation via cmd_install (Approach B).
774
+ # Linux — _install_scheduler_linux with cron `0 4 * * 0` + systemd
775
+ # OnCalendar `Sun *-*-* 04:00:00` for accurate wall-clock firing.
776
+ # Windows — TODO(t2183-followup): opencode on Windows is rare and the
777
+ # helper self-noops on missing DB, so leaving unscheduled is
778
+ # low-risk for this iteration.
779
+ setup_opencode_db_maintenance() {
780
+ local ocdbm_script="$HOME/.aidevops/agents/scripts/opencode-db-maintenance-helper.sh"
781
+ if ! [[ -x "$ocdbm_script" ]]; then
782
+ return 0
783
+ fi
784
+
785
+ local ocdbm_log_dir="$HOME/.aidevops/.agent-workspace/logs"
786
+ mkdir -p "$ocdbm_log_dir"
787
+
788
+ if [[ "$(uname -s)" == "Darwin" ]]; then
789
+ # Helper owns its own plist generation (Approach B, like repo-sync).
790
+ # Quiet the helper's multi-line output and emit one consolidated line
791
+ # to match the style of setup_profile_readme / setup_oauth_token_refresh.
792
+ if bash "$ocdbm_script" install >/dev/null 2>&1; then
793
+ print_info "OpenCode DB maintenance enabled (launchd, weekly Sun 04:00)"
794
+ else
795
+ print_warning "Failed to install opencode DB maintenance LaunchAgent"
796
+ fi
797
+ elif _is_windows; then
798
+ # Windows scheduling deferred — helper self-noops on missing DB so
799
+ # the cost of leaving unscheduled is ~0 until opencode lands on
800
+ # Windows in quantity.
801
+ return 0
802
+ else
803
+ # Linux / WSL: prefer systemd user timer, fall back to cron.
804
+ # Weekly Sunday 04:00 local — cron: `0 4 * * 0`; systemd OnCalendar
805
+ # ensures wall-clock firing even across suspends/reboots.
806
+ _install_scheduler_linux \
807
+ "aidevops-opencode-db-maintenance" \
808
+ "aidevops: opencode-db-maintenance" \
809
+ "0 4 * * 0" \
810
+ "\"${ocdbm_script}\" auto" \
811
+ "604800" \
812
+ "${ocdbm_log_dir}/opencode-db-maintenance.log" \
813
+ "" \
814
+ "OpenCode DB maintenance enabled (weekly Sun 04:00)" \
815
+ "Failed to install opencode DB maintenance scheduler" \
816
+ "false" \
817
+ "true" \
818
+ "Sun *-*-* 04:00:00"
819
+ fi
820
+ return 0
821
+ }
822
+
823
+ # Setup repo-sync scheduler if not already installed.
824
+ # Keeps local git repos up to date with daily ff-only pulls.
825
+ # Respects config: aidevops config set orchestration.repo_sync false
826
+ setup_repo_sync() {
827
+ local repo_sync_script="$HOME/.aidevops/agents/scripts/repo-sync-helper.sh"
828
+ if ! [[ -x "$repo_sync_script" ]] || ! is_feature_enabled repo_sync 2>/dev/null; then
829
+ return 0
830
+ fi
831
+
832
+ local _repo_sync_installed=false
833
+ if _launchd_has_agent "com.aidevops.aidevops-repo-sync"; then
834
+ _repo_sync_installed=true
835
+ elif _launchd_has_agent "sh.aidevops.repo-sync"; then
836
+ _repo_sync_installed=true
837
+ elif crontab -l 2>/dev/null | grep -qF "aidevops-repo-sync"; then
838
+ _repo_sync_installed=true
839
+ elif command -v systemctl >/dev/null 2>&1 &&
840
+ systemctl --user is-enabled "aidevops-repo-sync.timer" >/dev/null 2>&1; then
841
+ _repo_sync_installed=true
842
+ fi
843
+ if [[ "$_repo_sync_installed" == "false" ]]; then
844
+ if [[ "$NON_INTERACTIVE" == "true" ]]; then
845
+ bash "$repo_sync_script" enable >/dev/null 2>&1 || true
846
+ print_info "Repo sync enabled (daily). Disable: aidevops repo-sync disable"
847
+ else
848
+ echo ""
849
+ echo "Repo sync keeps your local git repos up to date by running"
850
+ echo "git pull --ff-only daily on clean repos on their default branch."
851
+ echo ""
852
+ setup_prompt enable_repo_sync "Enable daily repo sync? [Y/n]: " "Y"
853
+ if [[ "$enable_repo_sync" =~ ^[Yy]?$ || -z "$enable_repo_sync" ]]; then
854
+ bash "$repo_sync_script" enable
855
+ else
856
+ print_info "Skipped. Enable later: aidevops repo-sync enable"
857
+ fi
858
+ fi
859
+ fi
860
+ return 0
861
+ }
862
+
863
+ # Setup r914 repo-aidevops-health scheduler if not already installed.
864
+ # Daily drift keeper for repos.json: bumps stale .aidevops.json versions
865
+ # and surfaces missing-folder / no-init drift for human triage.
866
+ # Respects config: aidevops config set orchestration.repo_aidevops_health false
867
+ setup_repo_aidevops_health() {
868
+ local repo_health_script="$HOME/.aidevops/agents/scripts/repo-aidevops-health-helper.sh"
869
+ if ! [[ -x "$repo_health_script" ]] || ! is_feature_enabled repo_aidevops_health 2>/dev/null; then
870
+ return 0
871
+ fi
872
+
873
+ local _repo_health_installed=false
874
+ if _launchd_has_agent "sh.aidevops.repo-aidevops-health"; then
875
+ _repo_health_installed=true
876
+ elif crontab -l 2>/dev/null | grep -qF "aidevops-repo-aidevops-health"; then
877
+ _repo_health_installed=true
878
+ elif command -v systemctl >/dev/null 2>&1 &&
879
+ systemctl --user is-enabled "aidevops-repo-aidevops-health.timer" >/dev/null 2>&1; then
880
+ _repo_health_installed=true
881
+ fi
882
+ if [[ "$_repo_health_installed" == "false" ]]; then
883
+ if [[ "$NON_INTERACTIVE" == "true" ]]; then
884
+ bash "$repo_health_script" enable >/dev/null 2>&1 || true
885
+ print_info "r914 repo-aidevops-health enabled (daily @03:30). Disable: aidevops repo-aidevops-health disable"
886
+ else
887
+ echo ""
888
+ echo "r914 keeps \`.aidevops.json\` versions current across all registered"
889
+ echo "repos and surfaces registry drift (missing folders, unregistered git"
890
+ echo "repos) for human triage. Runs daily at 03:30."
891
+ echo ""
892
+ setup_prompt enable_repo_health "Enable daily r914 repo-aidevops-health? [Y/n]: " "Y"
893
+ if [[ "$enable_repo_health" =~ ^[Yy]?$ || -z "$enable_repo_health" ]]; then
894
+ bash "$repo_health_script" enable
895
+ else
896
+ print_info "Skipped. Enable later: aidevops repo-aidevops-health enable"
897
+ fi
898
+ fi
899
+ fi
900
+ return 0
901
+ }
902
+
903
+ # ============================================================================
904
+ # Peer productivity monitor (t2932)
905
+ # ============================================================================
906
+ #
907
+ # Adaptive cross-runner dispatch coordination: observes peer GitHub activity
908
+ # every 30 min and updates ~/.config/aidevops/dispatch-override.conf to
909
+ # `ignore` peers whose pulse is broken (claims issues but never PRs) and
910
+ # back to `honour` when they recover. Self-healing across the ecosystem —
911
+ # each runner observes peers independently, no central coordinator needed.
912
+ # Manual entries in dispatch-override.conf above the auto-managed marker
913
+ # always take precedence.
914
+
915
+ # Install peer-productivity-monitor launchd plist (macOS).
916
+ # Args: $1=label $2=script $3=log_dir
917
+ _install_peer_productivity_monitor_launchd() {
918
+ local ppm_label="$1"
919
+ local ppm_script="$2"
920
+ local _ppm_log_dir="$3"
921
+ local ppm_plist="$HOME/Library/LaunchAgents/${ppm_label}.plist"
922
+
923
+ local _xml_ppm_script _xml_ppm_home _xml_ppm_log_dir
924
+ _xml_ppm_script=$(_xml_escape "$ppm_script")
925
+ _xml_ppm_home=$(_xml_escape "$HOME")
926
+ _xml_ppm_log_dir=$(_xml_escape "$_ppm_log_dir")
927
+
928
+ local ppm_plist_content
929
+ ppm_plist_content=$(
930
+ cat <<PPM_PLIST
931
+ <?xml version="1.0" encoding="UTF-8"?>
932
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
933
+ <plist version="1.0">
934
+ <dict>
935
+ <key>Label</key>
936
+ <string>${ppm_label}</string>
937
+ <key>ProgramArguments</key>
938
+ <array>
939
+ <string>$(_xml_escape "$(_resolve_modern_bash)")</string>
940
+ <string>${_xml_ppm_script}</string>
941
+ <string>observe</string>
942
+ </array>
943
+ <key>StartInterval</key>
944
+ <integer>1800</integer>
945
+ <key>StandardOutPath</key>
946
+ <string>${_xml_ppm_log_dir}/peer-productivity-launchd.log</string>
947
+ <key>StandardErrorPath</key>
948
+ <string>${_xml_ppm_log_dir}/peer-productivity-launchd.log</string>
949
+ <key>EnvironmentVariables</key>
950
+ <dict>
951
+ <key>PATH</key>
952
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
953
+ <key>HOME</key>
954
+ <string>${_xml_ppm_home}</string>
955
+ </dict>
956
+ <key>RunAtLoad</key>
957
+ <true/>
958
+ <key>KeepAlive</key>
959
+ <false/>
960
+ <key>ProcessType</key>
961
+ <string>Background</string>
962
+ <key>LowPriorityBackgroundIO</key>
963
+ <true/>
964
+ <key>Nice</key>
965
+ <integer>10</integer>
966
+ </dict>
967
+ </plist>
968
+ PPM_PLIST
969
+ )
970
+
971
+ if _launchd_install_if_changed "$ppm_label" "$ppm_plist" "$ppm_plist_content"; then
972
+ print_info "Peer productivity monitor enabled (launchd, every 30 min)"
973
+ else
974
+ print_warning "Failed to load peer-productivity-monitor LaunchAgent"
975
+ fi
976
+ return 0
977
+ }
978
+
979
+ # Install peer-productivity-monitor via systemd or cron (Linux).
980
+ # Args: $1=script path, $2=log dir
981
+ _install_peer_productivity_monitor_linux() {
982
+ local ppm_script="$1"
983
+ local _ppm_log_dir="$2"
984
+ local ppm_systemd="aidevops-peer-productivity-monitor"
985
+ _install_scheduler_linux \
986
+ "$ppm_systemd" \
987
+ "aidevops: peer-productivity-monitor" \
988
+ "*/30 * * * *" \
989
+ "\"${ppm_script}\" observe" \
990
+ "1800" \
991
+ "${_ppm_log_dir}/peer-productivity-launchd.log" \
992
+ "" \
993
+ "Peer productivity monitor enabled (every 30 min)" \
994
+ "Failed to install peer-productivity-monitor scheduler" \
995
+ "true" \
996
+ "true"
997
+ return 0
998
+ }
999
+
1000
+ # Setup peer-productivity-monitor (t2932) — observes peer GitHub activity
1001
+ # every 30 min and updates ~/.config/aidevops/dispatch-override.conf so the
1002
+ # local pulse competes with broken peers and collaborates with healthy ones.
1003
+ # Manual entries in dispatch-override.conf above the auto-managed marker
1004
+ # always take precedence.
1005
+ setup_peer_productivity_monitor() {
1006
+ local ppm_script="$HOME/.aidevops/agents/scripts/peer-productivity-monitor.sh"
1007
+ local ppm_label="sh.aidevops.peer-productivity-monitor"
1008
+ if ! [[ -x "$ppm_script" ]]; then
1009
+ return 0
1010
+ fi
1011
+
1012
+ # Reuse contribution-watch's log-dir resolver (same logic, same config key).
1013
+ local _ppm_log_dir
1014
+ _ppm_log_dir=$(_resolve_cw_log_dir) || return 1
1015
+ mkdir -p "$_ppm_log_dir"
1016
+
1017
+ # Install/update scheduled runner
1018
+ if [[ "$(uname -s)" == "Darwin" ]]; then
1019
+ _install_peer_productivity_monitor_launchd "$ppm_label" "$ppm_script" "$_ppm_log_dir"
1020
+ else
1021
+ _install_peer_productivity_monitor_linux "$ppm_script" "$_ppm_log_dir"
1022
+ fi
1023
+ return 0
1024
+ }