bosun 0.26.3

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 (122) hide show
  1. package/.env.example +918 -0
  2. package/LICENSE +190 -0
  3. package/README.md +98 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/bosun.config.example.json +115 -0
  13. package/bosun.schema.json +465 -0
  14. package/claude-shell.mjs +708 -0
  15. package/cli.mjs +1028 -0
  16. package/codex-config.mjs +1274 -0
  17. package/codex-model-profiles.mjs +135 -0
  18. package/codex-shell.mjs +762 -0
  19. package/compat.mjs +286 -0
  20. package/config-doctor.mjs +613 -0
  21. package/config.mjs +1724 -0
  22. package/conflict-resolver.mjs +248 -0
  23. package/container-runner.mjs +450 -0
  24. package/copilot-shell.mjs +827 -0
  25. package/daemon-restart-policy.mjs +56 -0
  26. package/diff-stats.mjs +282 -0
  27. package/error-detector.mjs +829 -0
  28. package/fetch-runtime.mjs +34 -0
  29. package/fleet-coordinator.mjs +838 -0
  30. package/get-telegram-chat-id.mjs +71 -0
  31. package/git-safety.mjs +170 -0
  32. package/github-reconciler.mjs +403 -0
  33. package/hook-profiles.mjs +651 -0
  34. package/kanban-adapter.mjs +4491 -0
  35. package/lib/logger.mjs +645 -0
  36. package/maintenance.mjs +828 -0
  37. package/merge-strategy.mjs +1171 -0
  38. package/monitor.mjs +12237 -0
  39. package/package.json +209 -0
  40. package/postinstall.mjs +187 -0
  41. package/pr-cleanup-daemon.mjs +978 -0
  42. package/preflight.mjs +408 -0
  43. package/prepublish-check.mjs +90 -0
  44. package/presence.mjs +328 -0
  45. package/primary-agent.mjs +290 -0
  46. package/publish.mjs +241 -0
  47. package/repo-root.mjs +29 -0
  48. package/restart-controller.mjs +100 -0
  49. package/review-agent.mjs +557 -0
  50. package/rotate-agent-logs.sh +133 -0
  51. package/sdk-conflict-resolver.mjs +973 -0
  52. package/session-tracker.mjs +880 -0
  53. package/setup.mjs +3946 -0
  54. package/shared-knowledge.mjs +410 -0
  55. package/shared-state-manager.mjs +841 -0
  56. package/shared-workspace-cli.mjs +199 -0
  57. package/shared-workspace-registry.mjs +537 -0
  58. package/shared-workspaces.json +18 -0
  59. package/startup-service.mjs +1070 -0
  60. package/sync-engine.mjs +1063 -0
  61. package/task-archiver.mjs +801 -0
  62. package/task-assessment.mjs +550 -0
  63. package/task-claims.mjs +924 -0
  64. package/task-complexity.mjs +581 -0
  65. package/task-executor.mjs +5111 -0
  66. package/task-store.mjs +753 -0
  67. package/telegram-bot.mjs +9683 -0
  68. package/telegram-sentinel.mjs +2010 -0
  69. package/ui/app.js +867 -0
  70. package/ui/app.legacy.js +1464 -0
  71. package/ui/app.monolith.js +2488 -0
  72. package/ui/components/charts.js +226 -0
  73. package/ui/components/chat-view.js +567 -0
  74. package/ui/components/command-palette.js +587 -0
  75. package/ui/components/diff-viewer.js +190 -0
  76. package/ui/components/forms.js +357 -0
  77. package/ui/components/kanban-board.js +451 -0
  78. package/ui/components/session-list.js +305 -0
  79. package/ui/components/shared.js +525 -0
  80. package/ui/demo.html +640 -0
  81. package/ui/index.html +70 -0
  82. package/ui/modules/api.js +297 -0
  83. package/ui/modules/icons.js +461 -0
  84. package/ui/modules/router.js +81 -0
  85. package/ui/modules/settings-schema.js +261 -0
  86. package/ui/modules/state.js +679 -0
  87. package/ui/modules/telegram.js +331 -0
  88. package/ui/modules/utils.js +270 -0
  89. package/ui/styles/animations.css +140 -0
  90. package/ui/styles/base.css +98 -0
  91. package/ui/styles/components.css +2032 -0
  92. package/ui/styles/kanban.css +286 -0
  93. package/ui/styles/layout.css +810 -0
  94. package/ui/styles/sessions.css +841 -0
  95. package/ui/styles/variables.css +188 -0
  96. package/ui/styles.css +141 -0
  97. package/ui/styles.monolith.css +1046 -0
  98. package/ui/tabs/agents.js +1417 -0
  99. package/ui/tabs/chat.js +75 -0
  100. package/ui/tabs/control.js +892 -0
  101. package/ui/tabs/dashboard.js +515 -0
  102. package/ui/tabs/infra.js +537 -0
  103. package/ui/tabs/logs.js +783 -0
  104. package/ui/tabs/settings.js +1509 -0
  105. package/ui/tabs/tasks.js +1385 -0
  106. package/ui-server.mjs +4084 -0
  107. package/update-check.mjs +471 -0
  108. package/utils.mjs +172 -0
  109. package/ve-kanban.mjs +654 -0
  110. package/ve-kanban.ps1 +1365 -0
  111. package/ve-kanban.sh +18 -0
  112. package/ve-orchestrator.mjs +340 -0
  113. package/ve-orchestrator.ps1 +6546 -0
  114. package/ve-orchestrator.sh +18 -0
  115. package/vibe-kanban-wrapper.mjs +41 -0
  116. package/vk-error-resolver.mjs +470 -0
  117. package/vk-log-stream.mjs +914 -0
  118. package/whatsapp-channel.mjs +520 -0
  119. package/workspace-monitor.mjs +581 -0
  120. package/workspace-reaper.mjs +405 -0
  121. package/workspace-registry.mjs +238 -0
  122. package/worktree-manager.mjs +1266 -0
package/config.mjs ADDED
@@ -0,0 +1,1724 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * bosun — Configuration System
5
+ *
6
+ * Loads configuration from (in priority order):
7
+ * 1. CLI flags (--key value)
8
+ * 2. Environment variables
9
+ * 3. .env file
10
+ * 4. bosun.config.json (project config)
11
+ * 5. Built-in defaults
12
+ *
13
+ * Executor configuration supports N executors with weights and failover.
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
18
+ import { execSync } from "node:child_process";
19
+ import { fileURLToPath } from "node:url";
20
+ import { resolveAgentSdkConfig } from "./agent-sdk.mjs";
21
+ import {
22
+ ensureAgentPromptWorkspace,
23
+ getAgentPromptDefinitions,
24
+ resolveAgentPrompts,
25
+ } from "./agent-prompts.mjs";
26
+ import { applyAllCompatibility } from "./compat.mjs";
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+
30
+ const CONFIG_FILES = [
31
+ "bosun.config.json",
32
+ ".bosun.json",
33
+ "bosun.json",
34
+ ];
35
+
36
+ function hasSetupMarkers(dir) {
37
+ const markers = [".env", ...CONFIG_FILES];
38
+ return markers.some((name) => existsSync(resolve(dir, name)));
39
+ }
40
+
41
+ function hasConfigFiles(dir) {
42
+ return CONFIG_FILES.some((name) => existsSync(resolve(dir, name)));
43
+ }
44
+
45
+ function isPathInside(parent, child) {
46
+ const rel = relative(parent, child);
47
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
48
+ }
49
+
50
+ function isWslInteropRuntime() {
51
+ return Boolean(
52
+ process.env.WSL_DISTRO_NAME ||
53
+ process.env.WSL_INTEROP ||
54
+ (process.platform === "win32" &&
55
+ String(process.env.HOME || "")
56
+ .trim()
57
+ .startsWith("/home/")),
58
+ );
59
+ }
60
+
61
+ function resolveConfigDir(repoRoot) {
62
+ const repoPath = resolve(repoRoot || process.cwd());
63
+ const packageDir = resolve(__dirname);
64
+ if (isPathInside(repoPath, packageDir) || hasConfigFiles(packageDir)) {
65
+ return packageDir;
66
+ }
67
+ const preferWindowsDirs =
68
+ process.platform === "win32" && !isWslInteropRuntime();
69
+ const baseDir = preferWindowsDirs
70
+ ? process.env.APPDATA ||
71
+ process.env.LOCALAPPDATA ||
72
+ process.env.USERPROFILE ||
73
+ process.env.HOME ||
74
+ process.cwd()
75
+ : process.env.HOME ||
76
+ process.env.XDG_CONFIG_HOME ||
77
+ process.env.USERPROFILE ||
78
+ process.env.APPDATA ||
79
+ process.env.LOCALAPPDATA ||
80
+ process.cwd();
81
+ return resolve(baseDir, "bosun");
82
+ }
83
+
84
+ function ensurePromptWorkspaceGitIgnore(repoRoot) {
85
+ const gitignorePath = resolve(repoRoot, ".gitignore");
86
+ const entry = "/.bosun/";
87
+ let existing = "";
88
+ try {
89
+ if (existsSync(gitignorePath)) {
90
+ existing = readFileSync(gitignorePath, "utf8");
91
+ }
92
+ } catch {
93
+ return;
94
+ }
95
+ const hasEntry = existing
96
+ .split(/\r?\n/)
97
+ .map((line) => line.trim())
98
+ .includes(entry);
99
+ if (hasEntry) return;
100
+ const next =
101
+ existing.endsWith("\n") || !existing ? existing : `${existing}\n`;
102
+ try {
103
+ writeFileSync(gitignorePath, `${next}${entry}\n`, "utf8");
104
+ } catch {
105
+ /* best effort */
106
+ }
107
+ }
108
+
109
+ // ── .env loader ──────────────────────────────────────────────────────────────
110
+
111
+ function loadDotEnv(dir, options = {}) {
112
+ const { override = false } = options;
113
+ const envPath = resolve(dir, ".env");
114
+ if (!existsSync(envPath)) return;
115
+ const lines = readFileSync(envPath, "utf8").split("\n");
116
+ for (const line of lines) {
117
+ const trimmed = line.trim();
118
+ if (!trimmed || trimmed.startsWith("#")) continue;
119
+ const eqIdx = trimmed.indexOf("=");
120
+ if (eqIdx === -1) continue;
121
+ const key = trimmed.slice(0, eqIdx).trim();
122
+ let val = trimmed.slice(eqIdx + 1).trim();
123
+ // Strip surrounding quotes
124
+ if (
125
+ (val.startsWith('"') && val.endsWith('"')) ||
126
+ (val.startsWith("'") && val.endsWith("'"))
127
+ ) {
128
+ val = val.slice(1, -1);
129
+ }
130
+ if (override || !(key in process.env)) {
131
+ process.env[key] = val;
132
+ }
133
+ }
134
+ }
135
+
136
+ function loadDotEnvFile(envPath, options = {}) {
137
+ const { override = false } = options;
138
+ const resolved = resolve(envPath);
139
+ if (!existsSync(resolved)) return;
140
+ const lines = readFileSync(resolved, "utf8").split("\n");
141
+ for (const line of lines) {
142
+ const trimmed = line.trim();
143
+ if (!trimmed || trimmed.startsWith("#")) continue;
144
+ const eqIdx = trimmed.indexOf("=");
145
+ if (eqIdx === -1) continue;
146
+ const key = trimmed.slice(0, eqIdx).trim();
147
+ let val = trimmed.slice(eqIdx + 1).trim();
148
+ if (
149
+ (val.startsWith('"') && val.endsWith('"')) ||
150
+ (val.startsWith("'") && val.endsWith("'"))
151
+ ) {
152
+ val = val.slice(1, -1);
153
+ }
154
+ if (override || !(key in process.env)) {
155
+ process.env[key] = val;
156
+ }
157
+ }
158
+ }
159
+
160
+ function loadConfigFile(configDir) {
161
+ for (const name of CONFIG_FILES) {
162
+ const p = resolve(configDir, name);
163
+ if (!existsSync(p)) continue;
164
+ try {
165
+ const raw = JSON.parse(readFileSync(p, "utf8"));
166
+ return { path: p, data: raw };
167
+ } catch {
168
+ return { path: p, data: null, error: "invalid-json" };
169
+ }
170
+ }
171
+ // Hint about the example template
172
+ const examplePath = resolve(configDir, "bosun.config.example.json");
173
+ if (existsSync(examplePath)) {
174
+ console.log(
175
+ `[config] No bosun.config.json found. Copy the example:\n` +
176
+ ` cp ${examplePath} ${resolve(configDir, "bosun.config.json")}`,
177
+ );
178
+ }
179
+ return { path: null, data: null };
180
+ }
181
+
182
+ // ── CLI arg parser ───────────────────────────────────────────────────────────
183
+
184
+ function parseArgs(argv) {
185
+ const args = argv.slice(2);
186
+ const result = { _positional: [], _flags: new Set() };
187
+ for (let i = 0; i < args.length; i++) {
188
+ if (args[i].startsWith("--")) {
189
+ const key = args[i].slice(2);
190
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
191
+ result[key] = args[i + 1];
192
+ i++;
193
+ } else {
194
+ result._flags.add(key);
195
+ }
196
+ } else {
197
+ result._positional.push(args[i]);
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ // ── Config/profile helpers ───────────────────────────────────────────────────
204
+
205
+ function normalizeKey(value) {
206
+ return String(value || "")
207
+ .trim()
208
+ .toLowerCase();
209
+ }
210
+
211
+ function applyEnvProfile(profile, options = {}) {
212
+ if (!profile || typeof profile !== "object") return;
213
+ const env = profile.env;
214
+ if (!env || typeof env !== "object") return;
215
+ const override = profile.envOverride === true || options.override === true;
216
+ for (const [key, value] of Object.entries(env)) {
217
+ if (!override && key in process.env) continue;
218
+ process.env[key] = String(value);
219
+ }
220
+ }
221
+
222
+ function applyProfileOverrides(configData, profile) {
223
+ if (!configData || typeof configData !== "object") {
224
+ return configData || {};
225
+ }
226
+ if (!profile || typeof profile !== "object") {
227
+ return configData;
228
+ }
229
+ const overrides =
230
+ profile.overrides || profile.config || profile.settings || {};
231
+ if (!overrides || typeof overrides !== "object") {
232
+ return configData;
233
+ }
234
+ return {
235
+ ...configData,
236
+ ...overrides,
237
+ repositories: overrides.repositories ?? configData.repositories,
238
+ executors: overrides.executors ?? configData.executors,
239
+ failover: overrides.failover ?? configData.failover,
240
+ distribution: overrides.distribution ?? configData.distribution,
241
+ agentPrompts: overrides.agentPrompts ?? configData.agentPrompts,
242
+ };
243
+ }
244
+
245
+ function resolveRepoPath(repoPath, baseDir) {
246
+ if (!repoPath) return "";
247
+ if (repoPath.startsWith("~")) {
248
+ return resolve(
249
+ process.env.HOME || process.env.USERPROFILE || "",
250
+ repoPath.slice(1),
251
+ );
252
+ }
253
+ return resolve(baseDir, repoPath);
254
+ }
255
+
256
+ function parseEnvBoolean(value, defaultValue) {
257
+ if (value === undefined || value === null || value === "") {
258
+ return defaultValue;
259
+ }
260
+ const raw = String(value).trim().toLowerCase();
261
+ if (["true", "1", "yes", "y", "on"].includes(raw)) return true;
262
+ if (["false", "0", "no", "n", "off"].includes(raw)) return false;
263
+ return defaultValue;
264
+ }
265
+
266
+ function isEnvEnabled(value, defaultValue = false) {
267
+ return parseEnvBoolean(value, defaultValue);
268
+ }
269
+
270
+ // ── Git helpers ──────────────────────────────────────────────────────────────
271
+
272
+ function detectRepoSlug() {
273
+ try {
274
+ const remote = execSync("git remote get-url origin", {
275
+ encoding: "utf8",
276
+ stdio: ["pipe", "pipe", "ignore"],
277
+ }).trim();
278
+ const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)/);
279
+ return match ? match[1] : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function detectRepoRoot() {
286
+ try {
287
+ return execSync("git rev-parse --show-toplevel", {
288
+ encoding: "utf8",
289
+ stdio: ["pipe", "pipe", "ignore"],
290
+ }).trim();
291
+ } catch {
292
+ return process.cwd();
293
+ }
294
+ }
295
+
296
+ // ── Executor Configuration ───────────────────────────────────────────────────
297
+
298
+ /**
299
+ * Executor config schema:
300
+ *
301
+ * {
302
+ * "executors": [
303
+ * {
304
+ * "name": "copilot-claude",
305
+ * "executor": "COPILOT",
306
+ * "variant": "CLAUDE_OPUS_4_6",
307
+ * "weight": 50,
308
+ * "role": "primary",
309
+ * "enabled": true
310
+ * },
311
+ * {
312
+ * "name": "codex-default",
313
+ * "executor": "CODEX",
314
+ * "variant": "DEFAULT",
315
+ * "weight": 50,
316
+ * "role": "backup",
317
+ * "enabled": true
318
+ * }
319
+ * ],
320
+ * "failover": {
321
+ * "strategy": "next-in-line", // "next-in-line" | "weighted-random" | "round-robin"
322
+ * "maxRetries": 3,
323
+ * "cooldownMinutes": 5,
324
+ * "disableOnConsecutiveFailures": 3
325
+ * },
326
+ * "distribution": "weighted" // "weighted" | "round-robin" | "primary-only"
327
+ * }
328
+ */
329
+
330
+ const DEFAULT_EXECUTORS = {
331
+ executors: [
332
+ {
333
+ name: "codex-default",
334
+ executor: "CODEX",
335
+ variant: "DEFAULT",
336
+ weight: 100,
337
+ role: "primary",
338
+ enabled: true,
339
+ },
340
+ ],
341
+ failover: {
342
+ strategy: "next-in-line",
343
+ maxRetries: 3,
344
+ cooldownMinutes: 5,
345
+ disableOnConsecutiveFailures: 3,
346
+ },
347
+ distribution: "primary-only",
348
+ };
349
+
350
+ function parseExecutorsFromEnv() {
351
+ // EXECUTORS=CODEX:DEFAULT:100
352
+ const raw = process.env.EXECUTORS;
353
+ if (!raw) return null;
354
+ const entries = raw.split(",").map((e) => e.trim());
355
+ const executors = [];
356
+ const roles = ["primary", "backup", "tertiary"];
357
+ for (let i = 0; i < entries.length; i++) {
358
+ const parts = entries[i].split(":");
359
+ if (parts.length < 2) continue;
360
+ executors.push({
361
+ name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
362
+ executor: parts[0].toUpperCase(),
363
+ variant: parts[1],
364
+ weight: parts[2] ? Number(parts[2]) : Math.floor(100 / entries.length),
365
+ role: roles[i] || `executor-${i + 1}`,
366
+ enabled: true,
367
+ });
368
+ }
369
+ return executors.length ? executors : null;
370
+ }
371
+
372
+ function normalizePrimaryAgent(value) {
373
+ const raw = String(value || "")
374
+ .trim()
375
+ .toLowerCase();
376
+ if (!raw) return "codex-sdk";
377
+ if (["codex", "codex-sdk"].includes(raw)) return "codex-sdk";
378
+ if (["copilot", "copilot-sdk", "github-copilot"].includes(raw))
379
+ return "copilot-sdk";
380
+ if (["claude", "claude-sdk", "claude_code", "claude-code"].includes(raw))
381
+ return "claude-sdk";
382
+ return raw;
383
+ }
384
+
385
+ function normalizeKanbanBackend(value) {
386
+ const backend = String(value || "")
387
+ .trim()
388
+ .toLowerCase();
389
+ if (
390
+ backend === "internal" ||
391
+ backend === "github" ||
392
+ backend === "jira" ||
393
+ backend === "vk"
394
+ ) {
395
+ return backend;
396
+ }
397
+ return "internal";
398
+ }
399
+
400
+ function normalizeKanbanSyncPolicy(value) {
401
+ const policy = String(value || "")
402
+ .trim()
403
+ .toLowerCase();
404
+ if (policy === "internal-primary" || policy === "bidirectional") {
405
+ return policy;
406
+ }
407
+ return "internal-primary";
408
+ }
409
+
410
+ function normalizeProjectRequirementsProfile(value) {
411
+ const profile = String(value || "")
412
+ .trim()
413
+ .toLowerCase();
414
+ if (
415
+ [
416
+ "simple-feature",
417
+ "feature",
418
+ "large-feature",
419
+ "system",
420
+ "multi-system",
421
+ ].includes(profile)
422
+ ) {
423
+ return profile;
424
+ }
425
+ return "feature";
426
+ }
427
+
428
+ function loadExecutorConfig(configDir, configData) {
429
+ // 1. Try env var
430
+ const fromEnv = parseExecutorsFromEnv();
431
+
432
+ // 2. Try config file
433
+ let fromFile = null;
434
+ if (configData && typeof configData === "object") {
435
+ fromFile = configData.executors ? configData : null;
436
+ }
437
+ if (!fromFile) {
438
+ for (const name of CONFIG_FILES) {
439
+ const p = resolve(configDir, name);
440
+ if (existsSync(p)) {
441
+ try {
442
+ const raw = JSON.parse(readFileSync(p, "utf8"));
443
+ fromFile = raw.executors ? raw : null;
444
+ break;
445
+ } catch {
446
+ /* invalid JSON — skip */
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ const executors =
453
+ fromEnv || fromFile?.executors || DEFAULT_EXECUTORS.executors;
454
+ const failover = fromFile?.failover || {
455
+ strategy:
456
+ process.env.FAILOVER_STRATEGY || DEFAULT_EXECUTORS.failover.strategy,
457
+ maxRetries: Number(
458
+ process.env.FAILOVER_MAX_RETRIES || DEFAULT_EXECUTORS.failover.maxRetries,
459
+ ),
460
+ cooldownMinutes: Number(
461
+ process.env.FAILOVER_COOLDOWN_MIN ||
462
+ DEFAULT_EXECUTORS.failover.cooldownMinutes,
463
+ ),
464
+ disableOnConsecutiveFailures: Number(
465
+ process.env.FAILOVER_DISABLE_AFTER ||
466
+ DEFAULT_EXECUTORS.failover.disableOnConsecutiveFailures,
467
+ ),
468
+ };
469
+ const distribution =
470
+ fromFile?.distribution ||
471
+ process.env.EXECUTOR_DISTRIBUTION ||
472
+ DEFAULT_EXECUTORS.distribution;
473
+
474
+ return { executors, failover, distribution };
475
+ }
476
+
477
+ // ── Executor Scheduler ───────────────────────────────────────────────────────
478
+
479
+ class ExecutorScheduler {
480
+ constructor(config) {
481
+ this.executors = config.executors.filter((e) => e.enabled !== false);
482
+ this.failover = config.failover;
483
+ this.distribution = config.distribution;
484
+ this._roundRobinIndex = 0;
485
+ this._failureCounts = new Map(); // name → consecutive failures
486
+ this._disabledUntil = new Map(); // name → timestamp
487
+ }
488
+
489
+ /** Get the next executor based on distribution strategy */
490
+ next() {
491
+ const available = this._getAvailable();
492
+ if (!available.length) {
493
+ // All disabled — reset and use primary
494
+ this._disabledUntil.clear();
495
+ this._failureCounts.clear();
496
+ return this.executors[0];
497
+ }
498
+
499
+ switch (this.distribution) {
500
+ case "round-robin":
501
+ return this._roundRobin(available);
502
+ case "primary-only":
503
+ return available[0];
504
+ case "weighted":
505
+ default:
506
+ return this._weightedSelect(available);
507
+ }
508
+ }
509
+
510
+ /** Report a failure for an executor */
511
+ recordFailure(executorName) {
512
+ const count = (this._failureCounts.get(executorName) || 0) + 1;
513
+ this._failureCounts.set(executorName, count);
514
+ if (count >= this.failover.disableOnConsecutiveFailures) {
515
+ const until = Date.now() + this.failover.cooldownMinutes * 60 * 1000;
516
+ this._disabledUntil.set(executorName, until);
517
+ this._failureCounts.set(executorName, 0);
518
+ }
519
+ }
520
+
521
+ /** Report a success for an executor */
522
+ recordSuccess(executorName) {
523
+ this._failureCounts.set(executorName, 0);
524
+ this._disabledUntil.delete(executorName);
525
+ }
526
+
527
+ /** Get failover executor when current one fails */
528
+ getFailover(currentName) {
529
+ const available = this._getAvailable().filter(
530
+ (e) => e.name !== currentName,
531
+ );
532
+ if (!available.length) return null;
533
+
534
+ switch (this.failover.strategy) {
535
+ case "weighted-random":
536
+ return this._weightedSelect(available);
537
+ case "round-robin":
538
+ return available[0];
539
+ case "next-in-line":
540
+ default: {
541
+ // Find the next one by role priority
542
+ const roleOrder = [
543
+ "primary",
544
+ "backup",
545
+ "tertiary",
546
+ ...Array.from({ length: 20 }, (_, i) => `executor-${i + 1}`),
547
+ ];
548
+ available.sort(
549
+ (a, b) => roleOrder.indexOf(a.role) - roleOrder.indexOf(b.role),
550
+ );
551
+ return available[0];
552
+ }
553
+ }
554
+ }
555
+
556
+ /** Get summary for display */
557
+ getSummary() {
558
+ const total = this.executors.reduce((s, e) => s + e.weight, 0);
559
+ return this.executors.map((e) => {
560
+ const pct = total > 0 ? Math.round((e.weight / total) * 100) : 0;
561
+ const disabled = this._isDisabled(e.name);
562
+ return {
563
+ ...e,
564
+ percentage: pct,
565
+ status: disabled ? "cooldown" : e.enabled ? "active" : "disabled",
566
+ consecutiveFailures: this._failureCounts.get(e.name) || 0,
567
+ };
568
+ });
569
+ }
570
+
571
+ /** Format a display string like "COPILOT ⇄ CODEX (50/50)" */
572
+ toDisplayString() {
573
+ const summary = this.getSummary().filter((e) => e.status === "active");
574
+ if (!summary.length) return "No executors available";
575
+ return summary
576
+ .map((e) => `${e.executor}:${e.variant}(${e.percentage}%)`)
577
+ .join(" ⇄ ");
578
+ }
579
+
580
+ _getAvailable() {
581
+ return this.executors.filter(
582
+ (e) => e.enabled !== false && !this._isDisabled(e.name),
583
+ );
584
+ }
585
+
586
+ _isDisabled(name) {
587
+ const until = this._disabledUntil.get(name);
588
+ if (!until) return false;
589
+ if (Date.now() >= until) {
590
+ this._disabledUntil.delete(name);
591
+ return false;
592
+ }
593
+ return true;
594
+ }
595
+
596
+ _roundRobin(available) {
597
+ const idx = this._roundRobinIndex % available.length;
598
+ this._roundRobinIndex++;
599
+ return available[idx];
600
+ }
601
+
602
+ _weightedSelect(available) {
603
+ const totalWeight = available.reduce((s, e) => s + (e.weight || 1), 0);
604
+ let r = Math.random() * totalWeight;
605
+ for (const e of available) {
606
+ r -= e.weight || 1;
607
+ if (r <= 0) return e;
608
+ }
609
+ return available[available.length - 1];
610
+ }
611
+ }
612
+
613
+ // ── Multi-Repo Support ───────────────────────────────────────────────────────
614
+
615
+ /**
616
+ * Multi-repo config schema (supports defaults + selection):
617
+ *
618
+ * {
619
+ * "defaultRepository": "backend",
620
+ * "repositoryDefaults": {
621
+ * "orchestratorScript": "./orchestrator.ps1",
622
+ * "orchestratorArgs": "-MaxParallel 6",
623
+ * "profile": "local"
624
+ * },
625
+ * "repositories": [
626
+ * {
627
+ * "name": "backend",
628
+ * "path": "/path/to/backend",
629
+ * "slug": "org/backend",
630
+ * "primary": true
631
+ * },
632
+ * {
633
+ * "name": "frontend",
634
+ * "path": "/path/to/frontend",
635
+ * "slug": "org/frontend",
636
+ * "profile": "frontend"
637
+ * }
638
+ * ]
639
+ * }
640
+ */
641
+
642
+ function normalizeRepoEntry(entry, defaults, baseDir) {
643
+ if (!entry || typeof entry !== "object") return null;
644
+ const name = String(entry.name || entry.id || "").trim();
645
+ if (!name) return null;
646
+ const repoPath =
647
+ entry.path || entry.repoRoot || defaults.path || defaults.repoRoot || "";
648
+ const resolvedPath = repoPath ? resolveRepoPath(repoPath, baseDir) : "";
649
+ const slug = entry.slug || entry.repo || defaults.slug || defaults.repo || "";
650
+ const aliases = Array.isArray(entry.aliases)
651
+ ? entry.aliases.map(normalizeKey).filter(Boolean)
652
+ : [];
653
+ return {
654
+ ...defaults,
655
+ ...entry,
656
+ name,
657
+ id: normalizeKey(name),
658
+ path: resolvedPath,
659
+ slug,
660
+ aliases,
661
+ primary: entry.primary === true || defaults.primary === true,
662
+ };
663
+ }
664
+
665
+ function resolveRepoSelection(repositories, selection) {
666
+ if (!repositories || repositories.length === 0) return null;
667
+ const target = normalizeKey(selection);
668
+ if (!target) return null;
669
+ return (
670
+ repositories.find((repo) => repo.id === target) ||
671
+ repositories.find((repo) => normalizeKey(repo.name) === target) ||
672
+ repositories.find((repo) => normalizeKey(repo.slug) === target) ||
673
+ repositories.find((repo) => repo.aliases?.includes(target)) ||
674
+ null
675
+ );
676
+ }
677
+
678
+ function loadRepoConfig(configDir, configData = {}, options = {}) {
679
+ const repoRootOverride = options.repoRootOverride || "";
680
+ const baseDir = configDir || process.cwd();
681
+ const repoDefaults =
682
+ configData.repositoryDefaults || configData.repositories?.defaults || {};
683
+ let repoEntries = null;
684
+ if (Array.isArray(configData.repositories)) {
685
+ repoEntries = configData.repositories;
686
+ } else if (Array.isArray(configData.repositories?.items)) {
687
+ repoEntries = configData.repositories.items;
688
+ } else if (Array.isArray(configData.repositories?.list)) {
689
+ repoEntries = configData.repositories.list;
690
+ }
691
+
692
+ if (repoEntries && repoEntries.length) {
693
+ return repoEntries
694
+ .map((entry) => normalizeRepoEntry(entry, repoDefaults, baseDir))
695
+ .filter(Boolean);
696
+ }
697
+
698
+ const repoRoot = repoRootOverride || detectRepoRoot();
699
+ const slug = detectRepoSlug();
700
+ return [
701
+ {
702
+ name: basename(repoRoot),
703
+ id: normalizeKey(basename(repoRoot)),
704
+ path: repoRoot,
705
+ slug: process.env.GITHUB_REPO || slug || "unknown/unknown",
706
+ primary: true,
707
+ },
708
+ ];
709
+ }
710
+
711
+ function loadAgentPrompts(configDir, repoRoot, configData) {
712
+ const resolved = resolveAgentPrompts(configDir, repoRoot, configData);
713
+ return { ...resolved.prompts, _sources: resolved.sources };
714
+ }
715
+
716
+ // ── Main Configuration Loader ────────────────────────────────────────────────
717
+
718
+ /**
719
+ * Load the full bosun configuration.
720
+ * Returns a frozen config object used by all modules.
721
+ */
722
+ export function loadConfig(argv = process.argv, options = {}) {
723
+ // Apply legacy CODEX_MONITOR_* → BOSUN_* compatibility before anything else
724
+ applyAllCompatibility();
725
+
726
+ const { reloadEnv = false } = options;
727
+ const cli = parseArgs(argv);
728
+
729
+ const repoRootForConfig = detectRepoRoot();
730
+ // Determine config directory (where bosun stores its config)
731
+ const configDir =
732
+ cli["config-dir"] ||
733
+ process.env.BOSUN_DIR ||
734
+ resolveConfigDir(repoRootForConfig);
735
+
736
+ const configFile = loadConfigFile(configDir);
737
+ let configData = configFile.data || {};
738
+
739
+ const repoRootOverride = cli["repo-root"] || process.env.REPO_ROOT || "";
740
+ let repositories = loadRepoConfig(configDir, configData, {
741
+ repoRootOverride,
742
+ });
743
+
744
+ const repoSelection =
745
+ cli["repo-name"] ||
746
+ cli.repository ||
747
+ process.env.BOSUN_REPO ||
748
+ process.env.BOSUN_REPO_NAME ||
749
+ process.env.REPO_NAME ||
750
+ configData.defaultRepository ||
751
+ configData.defaultRepo ||
752
+ configData.repositories?.default ||
753
+ "";
754
+
755
+ let selectedRepository =
756
+ resolveRepoSelection(repositories, repoSelection) ||
757
+ repositories.find((repo) => repo.primary) ||
758
+ repositories[0] ||
759
+ null;
760
+
761
+ let repoRoot =
762
+ repoRootOverride || selectedRepository?.path || detectRepoRoot();
763
+
764
+ // Load .env from config dir
765
+ loadDotEnv(configDir, { override: reloadEnv });
766
+
767
+ // Also load .env from repo root if different
768
+ if (resolve(repoRoot) !== resolve(configDir)) {
769
+ loadDotEnv(repoRoot, { override: reloadEnv });
770
+ }
771
+
772
+ const initialRepoRoot = repoRoot;
773
+
774
+ const profiles = configData.profiles || configData.envProfiles || {};
775
+ const defaultProfile =
776
+ configData.defaultProfile ||
777
+ configData.defaultEnvProfile ||
778
+ (profiles.default ? "default" : "");
779
+ const profileName =
780
+ cli.profile ||
781
+ process.env.BOSUN_PROFILE ||
782
+ process.env.BOSUN_ENV_PROFILE ||
783
+ selectedRepository?.profile ||
784
+ selectedRepository?.envProfile ||
785
+ defaultProfile ||
786
+ "";
787
+ const profile = profileName ? profiles[profileName] : null;
788
+
789
+ if (profile?.envFile) {
790
+ const envFilePath = resolve(configDir, profile.envFile);
791
+ loadDotEnvFile(envFilePath, { override: profile.envOverride === true });
792
+ }
793
+ applyEnvProfile(profile, { override: reloadEnv });
794
+
795
+ // Apply profile overrides (executors, repos, etc.)
796
+ configData = applyProfileOverrides(configData, profile);
797
+ repositories = loadRepoConfig(configDir, configData, { repoRootOverride });
798
+ selectedRepository =
799
+ resolveRepoSelection(
800
+ repositories,
801
+ repoSelection ||
802
+ profile?.repository ||
803
+ profile?.repo ||
804
+ profile?.defaultRepository ||
805
+ "",
806
+ ) ||
807
+ repositories.find((repo) => repo.primary) ||
808
+ repositories[0] ||
809
+ null;
810
+ repoRoot = repoRootOverride || selectedRepository?.path || detectRepoRoot();
811
+
812
+ if (resolve(repoRoot) !== resolve(initialRepoRoot)) {
813
+ loadDotEnv(repoRoot, { override: reloadEnv });
814
+ }
815
+
816
+ const envPaths = [
817
+ resolve(configDir, ".env"),
818
+ resolve(repoRoot, ".env"),
819
+ ].filter((p, i, arr) => arr.indexOf(p) === i);
820
+
821
+ // ── Project identity ─────────────────────────────────────
822
+ const projectName =
823
+ cli["project-name"] ||
824
+ process.env.PROJECT_NAME ||
825
+ process.env.VK_PROJECT_NAME ||
826
+ selectedRepository?.projectName ||
827
+ configData.projectName ||
828
+ detectProjectName(configDir, repoRoot);
829
+
830
+ const repoSlug =
831
+ cli["repo"] ||
832
+ process.env.GITHUB_REPO ||
833
+ selectedRepository?.slug ||
834
+ detectRepoSlug() ||
835
+ "unknown/unknown";
836
+
837
+ const repoUrlBase =
838
+ process.env.GITHUB_REPO_URL ||
839
+ selectedRepository?.repoUrlBase ||
840
+ `https://github.com/${repoSlug}`;
841
+
842
+ const mode =
843
+ (
844
+ cli.mode ||
845
+ process.env.BOSUN_MODE ||
846
+ configData.mode ||
847
+ selectedRepository?.mode ||
848
+ ""
849
+ )
850
+ .toString()
851
+ .toLowerCase() ||
852
+ (String(findOrchestratorScript(configDir, repoRoot)).includes(
853
+ "ve-orchestrator",
854
+ )
855
+ ? "virtengine"
856
+ : "generic");
857
+
858
+ // ── Orchestrator ─────────────────────────────────────────
859
+ const defaultScript =
860
+ selectedRepository?.orchestratorScript ||
861
+ configData.orchestratorScript ||
862
+ findOrchestratorScript(configDir, repoRoot);
863
+ const defaultArgs =
864
+ mode === "virtengine" ? "-MaxParallel 6 -WaitForMutex" : "";
865
+ const rawScript =
866
+ cli.script || process.env.ORCHESTRATOR_SCRIPT || defaultScript;
867
+ // Resolve relative paths against configDir (not cwd) so that
868
+ // "../ve-orchestrator.ps1" always resolves to scripts/ve-orchestrator.ps1
869
+ // regardless of what directory the process was started from.
870
+ let scriptPath = resolve(configDir, rawScript);
871
+ // If the resolved path doesn't exist and rawScript is just a filename (no path separators),
872
+ // fall back to auto-detection to find it in common locations.
873
+ if (
874
+ !existsSync(scriptPath) &&
875
+ !rawScript.includes("/") &&
876
+ !rawScript.includes("\\")
877
+ ) {
878
+ const autoDetected = findOrchestratorScript(configDir, repoRoot);
879
+ if (existsSync(autoDetected)) {
880
+ scriptPath = autoDetected;
881
+ }
882
+ }
883
+ const scriptArgsRaw =
884
+ cli.args ||
885
+ process.env.ORCHESTRATOR_ARGS ||
886
+ selectedRepository?.orchestratorArgs ||
887
+ configData.orchestratorArgs ||
888
+ defaultArgs;
889
+ const scriptArgs = scriptArgsRaw.split(" ").filter(Boolean);
890
+
891
+ // ── Timing ───────────────────────────────────────────────
892
+ const restartDelayMs = Number(
893
+ cli["restart-delay"] || process.env.RESTART_DELAY_MS || "10000",
894
+ );
895
+ const maxRestarts = Number(
896
+ cli["max-restarts"] || process.env.MAX_RESTARTS || "0",
897
+ );
898
+
899
+ // ── Logging ──────────────────────────────────────────────
900
+ const logDir = resolve(
901
+ cli["log-dir"] ||
902
+ process.env.LOG_DIR ||
903
+ selectedRepository?.logDir ||
904
+ configData.logDir ||
905
+ resolve(configDir, "logs"),
906
+ );
907
+ // Max total size of the log directory in MB. 0 = unlimited.
908
+ const logMaxSizeMb = Number(
909
+ process.env.LOG_MAX_SIZE_MB ?? configData.logMaxSizeMb ?? 500,
910
+ );
911
+ // How often to check log folder size (minutes). 0 = only at startup.
912
+ const logCleanupIntervalMin = Number(
913
+ process.env.LOG_CLEANUP_INTERVAL_MIN ??
914
+ configData.logCleanupIntervalMin ??
915
+ 30,
916
+ );
917
+
918
+ // ── Agent SDK Selection ───────────────────────────────────
919
+ const agentSdk = resolveAgentSdkConfig();
920
+
921
+ // ── Feature flags ────────────────────────────────────────
922
+ const flags = cli._flags;
923
+ const watchEnabled = flags.has("no-watch")
924
+ ? false
925
+ : configData.watchEnabled !== undefined
926
+ ? configData.watchEnabled
927
+ : true;
928
+ const watchPath = resolve(
929
+ cli["watch-path"] ||
930
+ process.env.WATCH_PATH ||
931
+ selectedRepository?.watchPath ||
932
+ configData.watchPath ||
933
+ scriptPath,
934
+ );
935
+ const echoLogs = flags.has("echo-logs")
936
+ ? true
937
+ : flags.has("no-echo-logs")
938
+ ? false
939
+ : configData.echoLogs !== undefined
940
+ ? configData.echoLogs
941
+ : false;
942
+ const autoFixEnabled = flags.has("no-autofix")
943
+ ? false
944
+ : configData.autoFixEnabled !== undefined
945
+ ? configData.autoFixEnabled
946
+ : true;
947
+ const interactiveShellEnabled =
948
+ flags.has("shell") ||
949
+ flags.has("interactive") ||
950
+ isEnvEnabled(process.env.BOSUN_SHELL, false) ||
951
+ isEnvEnabled(process.env.BOSUN_INTERACTIVE, false) ||
952
+ configData.interactiveShellEnabled === true ||
953
+ configData.shellEnabled === true;
954
+ const preflightEnabled = flags.has("no-preflight")
955
+ ? false
956
+ : configData.preflightEnabled !== undefined
957
+ ? configData.preflightEnabled
958
+ : isEnvEnabled(process.env.BOSUN_PREFLIGHT_DISABLED, false)
959
+ ? false
960
+ : true;
961
+ const preflightRetryMs = Number(
962
+ cli["preflight-retry"] ||
963
+ process.env.BOSUN_PREFLIGHT_RETRY_MS ||
964
+ configData.preflightRetryMs ||
965
+ "300000",
966
+ );
967
+ const codexEnabled =
968
+ !flags.has("no-codex") &&
969
+ (configData.codexEnabled !== undefined ? configData.codexEnabled : true) &&
970
+ !isEnvEnabled(process.env.CODEX_SDK_DISABLED, false) &&
971
+ agentSdk.primary === "codex";
972
+ const primaryAgent = normalizePrimaryAgent(
973
+ cli["primary-agent"] ||
974
+ cli.agent ||
975
+ process.env.PRIMARY_AGENT ||
976
+ process.env.PRIMARY_AGENT_SDK ||
977
+ configData.primaryAgent ||
978
+ "codex-sdk",
979
+ );
980
+ const primaryAgentEnabled = isEnvEnabled(
981
+ process.env.PRIMARY_AGENT_DISABLED,
982
+ false,
983
+ )
984
+ ? false
985
+ : primaryAgent === "codex-sdk"
986
+ ? codexEnabled
987
+ : primaryAgent === "copilot-sdk"
988
+ ? !isEnvEnabled(process.env.COPILOT_SDK_DISABLED, false)
989
+ : !isEnvEnabled(process.env.CLAUDE_SDK_DISABLED, false);
990
+
991
+ // agentPoolEnabled: true when ANY agent SDK is available for pooled operations
992
+ // This decouples pooled prompt execution from specific SDK selection
993
+ const agentPoolEnabled =
994
+ !isEnvEnabled(process.env.CODEX_SDK_DISABLED, false) ||
995
+ !isEnvEnabled(process.env.COPILOT_SDK_DISABLED, false) ||
996
+ !isEnvEnabled(process.env.CLAUDE_SDK_DISABLED, false);
997
+
998
+ // ── Internal Executor ────────────────────────────────────
999
+ // Allows the monitor to run tasks via agent-pool directly instead of
1000
+ // (or alongside) the VK executor. Modes: "internal" (default), "vk", "hybrid".
1001
+ const kanbanBackend = normalizeKanbanBackend(
1002
+ process.env.KANBAN_BACKEND || configData.kanban?.backend || "internal",
1003
+ );
1004
+ const kanbanSyncPolicy = normalizeKanbanSyncPolicy(
1005
+ process.env.KANBAN_SYNC_POLICY || configData.kanban?.syncPolicy,
1006
+ );
1007
+ const kanban = Object.freeze({
1008
+ backend: kanbanBackend,
1009
+ projectId:
1010
+ process.env.KANBAN_PROJECT_ID || configData.kanban?.projectId || null,
1011
+ syncPolicy: kanbanSyncPolicy,
1012
+ });
1013
+ const githubProjectSync = Object.freeze({
1014
+ webhookPath:
1015
+ process.env.GITHUB_PROJECT_WEBHOOK_PATH ||
1016
+ configData.kanban?.github?.project?.webhook?.path ||
1017
+ "/api/webhooks/github/project-sync",
1018
+ webhookSecret:
1019
+ process.env.GITHUB_PROJECT_WEBHOOK_SECRET ||
1020
+ process.env.GITHUB_WEBHOOK_SECRET ||
1021
+ configData.kanban?.github?.project?.webhook?.secret ||
1022
+ "",
1023
+ webhookRequireSignature: isEnvEnabled(
1024
+ process.env.GITHUB_PROJECT_WEBHOOK_REQUIRE_SIGNATURE ??
1025
+ configData.kanban?.github?.project?.webhook?.requireSignature,
1026
+ Boolean(
1027
+ process.env.GITHUB_PROJECT_WEBHOOK_SECRET ||
1028
+ process.env.GITHUB_WEBHOOK_SECRET ||
1029
+ configData.kanban?.github?.project?.webhook?.secret,
1030
+ ),
1031
+ ),
1032
+ alertFailureThreshold: Math.max(
1033
+ 1,
1034
+ Number(
1035
+ process.env.GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD ||
1036
+ configData.kanban?.github?.project?.syncMonitoring
1037
+ ?.alertFailureThreshold ||
1038
+ 3,
1039
+ ),
1040
+ ),
1041
+ rateLimitAlertThreshold: Math.max(
1042
+ 1,
1043
+ Number(
1044
+ process.env.GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD ||
1045
+ configData.kanban?.github?.project?.syncMonitoring
1046
+ ?.rateLimitAlertThreshold ||
1047
+ 3,
1048
+ ),
1049
+ ),
1050
+ });
1051
+ const jira = Object.freeze({
1052
+ baseUrl:
1053
+ process.env.JIRA_BASE_URL || configData.kanban?.jira?.baseUrl || "",
1054
+ email: process.env.JIRA_EMAIL || configData.kanban?.jira?.email || "",
1055
+ apiToken:
1056
+ process.env.JIRA_API_TOKEN || configData.kanban?.jira?.apiToken || "",
1057
+ projectKey:
1058
+ process.env.JIRA_PROJECT_KEY || configData.kanban?.jira?.projectKey || "",
1059
+ issueType:
1060
+ process.env.JIRA_ISSUE_TYPE ||
1061
+ configData.kanban?.jira?.issueType ||
1062
+ "Task",
1063
+ baseBranchField:
1064
+ process.env.JIRA_CUSTOM_FIELD_BASE_BRANCH ||
1065
+ configData.kanban?.jira?.baseBranchField ||
1066
+ "",
1067
+ statusMapping: Object.freeze({
1068
+ todo:
1069
+ process.env.JIRA_STATUS_TODO ||
1070
+ configData.kanban?.jira?.statusMapping?.todo ||
1071
+ "To Do",
1072
+ inprogress:
1073
+ process.env.JIRA_STATUS_INPROGRESS ||
1074
+ configData.kanban?.jira?.statusMapping?.inprogress ||
1075
+ "In Progress",
1076
+ inreview:
1077
+ process.env.JIRA_STATUS_INREVIEW ||
1078
+ configData.kanban?.jira?.statusMapping?.inreview ||
1079
+ "In Review",
1080
+ done:
1081
+ process.env.JIRA_STATUS_DONE ||
1082
+ configData.kanban?.jira?.statusMapping?.done ||
1083
+ "Done",
1084
+ cancelled:
1085
+ process.env.JIRA_STATUS_CANCELLED ||
1086
+ configData.kanban?.jira?.statusMapping?.cancelled ||
1087
+ "Cancelled",
1088
+ }),
1089
+ labels: Object.freeze({
1090
+ claimed:
1091
+ process.env.JIRA_LABEL_CLAIMED ||
1092
+ configData.kanban?.jira?.labels?.claimed ||
1093
+ "codex:claimed",
1094
+ working:
1095
+ process.env.JIRA_LABEL_WORKING ||
1096
+ configData.kanban?.jira?.labels?.working ||
1097
+ "codex:working",
1098
+ stale:
1099
+ process.env.JIRA_LABEL_STALE ||
1100
+ configData.kanban?.jira?.labels?.stale ||
1101
+ "codex:stale",
1102
+ ignore:
1103
+ process.env.JIRA_LABEL_IGNORE ||
1104
+ configData.kanban?.jira?.labels?.ignore ||
1105
+ "codex:ignore",
1106
+ }),
1107
+ sharedStateFields: Object.freeze({
1108
+ ownerId:
1109
+ process.env.JIRA_CUSTOM_FIELD_OWNER_ID ||
1110
+ configData.kanban?.jira?.sharedStateFields?.ownerId ||
1111
+ "",
1112
+ attemptToken:
1113
+ process.env.JIRA_CUSTOM_FIELD_ATTEMPT_TOKEN ||
1114
+ configData.kanban?.jira?.sharedStateFields?.attemptToken ||
1115
+ "",
1116
+ attemptStarted:
1117
+ process.env.JIRA_CUSTOM_FIELD_ATTEMPT_STARTED ||
1118
+ configData.kanban?.jira?.sharedStateFields?.attemptStarted ||
1119
+ "",
1120
+ heartbeat:
1121
+ process.env.JIRA_CUSTOM_FIELD_HEARTBEAT ||
1122
+ configData.kanban?.jira?.sharedStateFields?.heartbeat ||
1123
+ "",
1124
+ retryCount:
1125
+ process.env.JIRA_CUSTOM_FIELD_RETRY_COUNT ||
1126
+ configData.kanban?.jira?.sharedStateFields?.retryCount ||
1127
+ "",
1128
+ ignoreReason:
1129
+ process.env.JIRA_CUSTOM_FIELD_IGNORE_REASON ||
1130
+ configData.kanban?.jira?.sharedStateFields?.ignoreReason ||
1131
+ "",
1132
+ }),
1133
+ });
1134
+
1135
+ const internalExecutorConfig = configData.internalExecutor || {};
1136
+ const projectRequirements = {
1137
+ profile: normalizeProjectRequirementsProfile(
1138
+ process.env.PROJECT_REQUIREMENTS_PROFILE ||
1139
+ configData.projectRequirements?.profile ||
1140
+ internalExecutorConfig.projectRequirements?.profile ||
1141
+ "feature",
1142
+ ),
1143
+ notes: String(
1144
+ process.env.PROJECT_REQUIREMENTS_NOTES ||
1145
+ configData.projectRequirements?.notes ||
1146
+ internalExecutorConfig.projectRequirements?.notes ||
1147
+ "",
1148
+ ).trim(),
1149
+ };
1150
+ const replenishMin = Math.max(
1151
+ 1,
1152
+ Math.min(
1153
+ 2,
1154
+ Number(
1155
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MIN_NEW_TASKS ||
1156
+ internalExecutorConfig.backlogReplenishment?.minNewTasks ||
1157
+ 1,
1158
+ ),
1159
+ ),
1160
+ );
1161
+ const replenishMax = Math.max(
1162
+ replenishMin,
1163
+ Math.min(
1164
+ 3,
1165
+ Number(
1166
+ process.env.INTERNAL_EXECUTOR_REPLENISH_MAX_NEW_TASKS ||
1167
+ internalExecutorConfig.backlogReplenishment?.maxNewTasks ||
1168
+ 2,
1169
+ ),
1170
+ ),
1171
+ );
1172
+ const executorMode = (
1173
+ process.env.EXECUTOR_MODE ||
1174
+ internalExecutorConfig.mode ||
1175
+ "internal"
1176
+ ).toLowerCase();
1177
+ const reviewAgentToggleRaw =
1178
+ process.env.INTERNAL_EXECUTOR_REVIEW_AGENT_ENABLED;
1179
+ const reviewAgentEnabled =
1180
+ reviewAgentToggleRaw !== undefined &&
1181
+ String(reviewAgentToggleRaw).trim() !== ""
1182
+ ? isEnvEnabled(reviewAgentToggleRaw, true)
1183
+ : internalExecutorConfig.reviewAgentEnabled !== false;
1184
+ const internalExecutor = {
1185
+ mode: ["vk", "internal", "hybrid"].includes(executorMode)
1186
+ ? executorMode
1187
+ : "internal",
1188
+ maxParallel: Number(
1189
+ process.env.INTERNAL_EXECUTOR_PARALLEL ||
1190
+ internalExecutorConfig.maxParallel ||
1191
+ 3,
1192
+ ),
1193
+ baseBranchParallelLimit: Number(
1194
+ process.env.INTERNAL_EXECUTOR_BASE_BRANCH_PARALLEL ||
1195
+ internalExecutorConfig.baseBranchParallelLimit ||
1196
+ 0,
1197
+ ),
1198
+ pollIntervalMs: Number(
1199
+ process.env.INTERNAL_EXECUTOR_POLL_MS ||
1200
+ internalExecutorConfig.pollIntervalMs ||
1201
+ 30000,
1202
+ ),
1203
+ sdk:
1204
+ process.env.INTERNAL_EXECUTOR_SDK || internalExecutorConfig.sdk || "auto",
1205
+ taskTimeoutMs: Number(
1206
+ process.env.INTERNAL_EXECUTOR_TIMEOUT_MS ||
1207
+ internalExecutorConfig.taskTimeoutMs ||
1208
+ 90 * 60 * 1000,
1209
+ ),
1210
+ maxRetries: Number(
1211
+ process.env.INTERNAL_EXECUTOR_MAX_RETRIES ||
1212
+ internalExecutorConfig.maxRetries ||
1213
+ 2,
1214
+ ),
1215
+ autoCreatePr: internalExecutorConfig.autoCreatePr !== false,
1216
+ projectId:
1217
+ process.env.INTERNAL_EXECUTOR_PROJECT_ID ||
1218
+ internalExecutorConfig.projectId ||
1219
+ null,
1220
+ reviewAgentEnabled,
1221
+ reviewMaxConcurrent: Number(
1222
+ process.env.INTERNAL_EXECUTOR_REVIEW_MAX_CONCURRENT ||
1223
+ internalExecutorConfig.reviewMaxConcurrent ||
1224
+ 2,
1225
+ ),
1226
+ reviewTimeoutMs: Number(
1227
+ process.env.INTERNAL_EXECUTOR_REVIEW_TIMEOUT_MS ||
1228
+ internalExecutorConfig.reviewTimeoutMs ||
1229
+ 300000,
1230
+ ),
1231
+ taskClaimOwnerStaleTtlMs: Number(
1232
+ process.env.TASK_CLAIM_OWNER_STALE_TTL_MS ||
1233
+ internalExecutorConfig.taskClaimOwnerStaleTtlMs ||
1234
+ 10 * 60 * 1000,
1235
+ ),
1236
+ taskClaimRenewIntervalMs: Number(
1237
+ process.env.TASK_CLAIM_RENEW_INTERVAL_MS ||
1238
+ internalExecutorConfig.taskClaimRenewIntervalMs ||
1239
+ 5 * 60 * 1000,
1240
+ ),
1241
+ backlogReplenishment: {
1242
+ enabled: isEnvEnabled(
1243
+ process.env.INTERNAL_EXECUTOR_REPLENISH_ENABLED,
1244
+ internalExecutorConfig.backlogReplenishment?.enabled === true,
1245
+ ),
1246
+ minNewTasks: replenishMin,
1247
+ maxNewTasks: replenishMax,
1248
+ requirePriority: isEnvEnabled(
1249
+ process.env.INTERNAL_EXECUTOR_REPLENISH_REQUIRE_PRIORITY,
1250
+ internalExecutorConfig.backlogReplenishment?.requirePriority !== false,
1251
+ ),
1252
+ },
1253
+ projectRequirements,
1254
+ };
1255
+
1256
+ // ── Vibe-Kanban ──────────────────────────────────────────
1257
+ const vkRecoveryPort = process.env.VK_RECOVERY_PORT || "54089";
1258
+ const vkRecoveryHost =
1259
+ process.env.VK_RECOVERY_HOST || process.env.VK_HOST || "0.0.0.0";
1260
+ const vkEndpointUrl =
1261
+ process.env.VK_ENDPOINT_URL ||
1262
+ process.env.VK_BASE_URL ||
1263
+ `http://127.0.0.1:${vkRecoveryPort}`;
1264
+ const vkPublicUrl = process.env.VK_PUBLIC_URL || process.env.VK_WEB_URL || "";
1265
+ const vkTaskUrlTemplate = process.env.VK_TASK_URL_TEMPLATE || "";
1266
+ const vkRecoveryCooldownMin = Number(
1267
+ process.env.VK_RECOVERY_COOLDOWN_MIN || "10",
1268
+ );
1269
+ const vkSpawnDefault =
1270
+ configData.vkSpawnEnabled !== undefined
1271
+ ? configData.vkSpawnEnabled
1272
+ : mode !== "generic";
1273
+ const vkRequiredByExecutor =
1274
+ internalExecutor.mode === "vk" || internalExecutor.mode === "hybrid";
1275
+ const vkRequiredByBoard = kanban.backend === "vk";
1276
+ const vkRuntimeRequired = vkRequiredByExecutor || vkRequiredByBoard;
1277
+ const vkSpawnEnabled =
1278
+ vkRuntimeRequired &&
1279
+ !flags.has("no-vk-spawn") &&
1280
+ !isEnvEnabled(process.env.VK_NO_SPAWN, false) &&
1281
+ vkSpawnDefault;
1282
+ const vkEnsureIntervalMs = Number(
1283
+ cli["vk-ensure-interval"] || process.env.VK_ENSURE_INTERVAL || "60000",
1284
+ );
1285
+
1286
+ // ── Telegram ─────────────────────────────────────────────
1287
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN || "";
1288
+ const telegramChatId = process.env.TELEGRAM_CHAT_ID || "";
1289
+ const telegramIntervalMin = Number(process.env.TELEGRAM_INTERVAL_MIN || "10");
1290
+ const telegramCommandPollTimeoutSec = Math.max(
1291
+ 5,
1292
+ Number(process.env.TELEGRAM_COMMAND_POLL_TIMEOUT_SEC || "20"),
1293
+ );
1294
+ const telegramCommandConcurrency = Math.max(
1295
+ 1,
1296
+ Number(process.env.TELEGRAM_COMMAND_CONCURRENCY || "2"),
1297
+ );
1298
+ const telegramCommandMaxBatch = Math.max(
1299
+ 1,
1300
+ Number(process.env.TELEGRAM_COMMAND_MAX_BATCH || "25"),
1301
+ );
1302
+ const telegramBotEnabled = !flags.has("no-telegram-bot") && !!telegramToken;
1303
+ const telegramCommandEnabled = flags.has("telegram-commands")
1304
+ ? !telegramBotEnabled
1305
+ : false;
1306
+ // Verbosity: minimal (critical+error only), summary (default — up to warnings
1307
+ // + key info), detailed (everything including debug).
1308
+ const telegramVerbosity = (
1309
+ process.env.TELEGRAM_VERBOSITY ||
1310
+ configData.telegramVerbosity ||
1311
+ "summary"
1312
+ ).toLowerCase();
1313
+
1314
+ // ── Task Planner ─────────────────────────────────────────
1315
+ // Mode: "codex-sdk" (default) runs Codex directly, "kanban" creates a VK
1316
+ // task for a real agent to plan, "disabled" turns off the planner entirely.
1317
+ const plannerMode = (
1318
+ process.env.TASK_PLANNER_MODE ||
1319
+ configData.plannerMode ||
1320
+ (mode === "generic" ? "disabled" : "codex-sdk")
1321
+ ).toLowerCase();
1322
+ const plannerPerCapitaThreshold = Number(
1323
+ process.env.TASK_PLANNER_PER_CAPITA_THRESHOLD || "1",
1324
+ );
1325
+ const plannerIdleSlotThreshold = Number(
1326
+ process.env.TASK_PLANNER_IDLE_SLOT_THRESHOLD || "1",
1327
+ );
1328
+ const plannerDedupHours = Number(process.env.TASK_PLANNER_DEDUP_HOURS || "6");
1329
+ const plannerDedupMs = Number.isFinite(plannerDedupHours)
1330
+ ? plannerDedupHours * 60 * 60 * 1000
1331
+ : 24 * 60 * 60 * 1000;
1332
+
1333
+ // ── GitHub Reconciler ───────────────────────────────────
1334
+ const ghReconcileEnabled = isEnvEnabled(
1335
+ process.env.GH_RECONCILE_ENABLED ?? configData.ghReconcileEnabled,
1336
+ process.env.VITEST ? false : true,
1337
+ );
1338
+ const ghReconcileIntervalMs = Number(
1339
+ process.env.GH_RECONCILE_INTERVAL_MS ||
1340
+ configData.ghReconcileIntervalMs ||
1341
+ 5 * 60 * 1000,
1342
+ );
1343
+ const ghReconcileMergedLookbackHours = Number(
1344
+ process.env.GH_RECONCILE_MERGED_LOOKBACK_HOURS ||
1345
+ configData.ghReconcileMergedLookbackHours ||
1346
+ 72,
1347
+ );
1348
+ const ghReconcileTrackingLabels = String(
1349
+ process.env.GH_RECONCILE_TRACKING_LABELS ||
1350
+ configData.ghReconcileTrackingLabels ||
1351
+ "tracking",
1352
+ )
1353
+ .split(",")
1354
+ .map((value) => value.trim().toLowerCase())
1355
+ .filter(Boolean);
1356
+
1357
+ // ── Branch Routing ────────────────────────────────────────
1358
+ // Maps scope patterns (from conventional commit scopes in task titles) to
1359
+ // upstream branches. Allows e.g. all "bosun" tasks to route to
1360
+ // "origin/ve/bosun-staging" instead of the default target branch.
1361
+ //
1362
+ // Config format (bosun.config.json):
1363
+ // "branchRouting": {
1364
+ // "defaultBranch": "origin/staging",
1365
+ // "scopeMap": {
1366
+ // "bosun": "origin/ve/bosun-staging",
1367
+ // "veid": "origin/staging",
1368
+ // "provider": "origin/staging"
1369
+ // },
1370
+ // "autoRebaseOnMerge": true,
1371
+ // "assessWithSdk": true
1372
+ // }
1373
+ //
1374
+ // Env overrides:
1375
+ // VK_TARGET_BRANCH=origin/staging (default branch)
1376
+ // BRANCH_ROUTING_SCOPE_MAP=bosun:origin/ve/bosun-staging,veid:origin/staging
1377
+ // AUTO_REBASE_ON_MERGE=true
1378
+ // ASSESS_WITH_SDK=true
1379
+ const branchRoutingRaw = configData.branchRouting || {};
1380
+ const defaultTargetBranch =
1381
+ process.env.VK_TARGET_BRANCH ||
1382
+ branchRoutingRaw.defaultBranch ||
1383
+ "origin/main";
1384
+ const scopeMapEnv = process.env.BRANCH_ROUTING_SCOPE_MAP || "";
1385
+ const scopeMapFromEnv = {};
1386
+ if (scopeMapEnv) {
1387
+ for (const pair of scopeMapEnv.split(",")) {
1388
+ const [scope, branch] = pair.split(":").map((s) => s.trim());
1389
+ if (scope && branch) scopeMapFromEnv[scope.toLowerCase()] = branch;
1390
+ }
1391
+ }
1392
+ const scopeMap = {
1393
+ ...(branchRoutingRaw.scopeMap || {}),
1394
+ ...scopeMapFromEnv,
1395
+ };
1396
+ // Normalise keys to lowercase
1397
+ const normalizedScopeMap = {};
1398
+ for (const [key, val] of Object.entries(scopeMap)) {
1399
+ normalizedScopeMap[key.toLowerCase()] = val;
1400
+ }
1401
+ const autoRebaseOnMerge = isEnvEnabled(
1402
+ process.env.AUTO_REBASE_ON_MERGE ?? branchRoutingRaw.autoRebaseOnMerge,
1403
+ true,
1404
+ );
1405
+ const assessWithSdk = isEnvEnabled(
1406
+ process.env.ASSESS_WITH_SDK ?? branchRoutingRaw.assessWithSdk,
1407
+ true,
1408
+ );
1409
+ const branchRouting = Object.freeze({
1410
+ defaultBranch: defaultTargetBranch,
1411
+ scopeMap: Object.freeze(normalizedScopeMap),
1412
+ autoRebaseOnMerge,
1413
+ assessWithSdk,
1414
+ });
1415
+
1416
+ // ── Fleet Coordination ─────────────────────────────────────
1417
+ // Multi-workstation collaboration: when 2+ bosun instances share
1418
+ // the same repo, the fleet system coordinates task planning, dispatch,
1419
+ // and conflict-aware ordering.
1420
+ const fleetEnabled = isEnvEnabled(
1421
+ process.env.FLEET_ENABLED ?? configData.fleetEnabled,
1422
+ true,
1423
+ );
1424
+ const fleetBufferMultiplier = Number(
1425
+ process.env.FLEET_BUFFER_MULTIPLIER ||
1426
+ configData.fleetBufferMultiplier ||
1427
+ "3",
1428
+ );
1429
+ const fleetSyncIntervalMs = Number(
1430
+ process.env.FLEET_SYNC_INTERVAL_MS ||
1431
+ configData.fleetSyncIntervalMs ||
1432
+ String(2 * 60 * 1000), // 2 minutes
1433
+ );
1434
+ const fleetPresenceTtlMs = Number(
1435
+ process.env.FLEET_PRESENCE_TTL_MS ||
1436
+ configData.fleetPresenceTtlMs ||
1437
+ String(5 * 60 * 1000), // 5 minutes
1438
+ );
1439
+ const fleetKnowledgeEnabled = isEnvEnabled(
1440
+ process.env.FLEET_KNOWLEDGE_ENABLED ?? configData.fleetKnowledgeEnabled,
1441
+ true,
1442
+ );
1443
+ const fleetKnowledgeFile = String(
1444
+ process.env.FLEET_KNOWLEDGE_FILE ||
1445
+ configData.fleetKnowledgeFile ||
1446
+ "AGENTS.md",
1447
+ );
1448
+ const fleet = Object.freeze({
1449
+ enabled: fleetEnabled,
1450
+ bufferMultiplier: fleetBufferMultiplier,
1451
+ syncIntervalMs: fleetSyncIntervalMs,
1452
+ presenceTtlMs: fleetPresenceTtlMs,
1453
+ knowledgeEnabled: fleetKnowledgeEnabled,
1454
+ knowledgeFile: fleetKnowledgeFile,
1455
+ });
1456
+
1457
+ // ── Dependabot Auto-Merge ─────────────────────────────────
1458
+ const dependabotAutoMerge = isEnvEnabled(
1459
+ process.env.DEPENDABOT_AUTO_MERGE ?? configData.dependabotAutoMerge,
1460
+ true,
1461
+ );
1462
+ const dependabotAutoMergeIntervalMin = Number(
1463
+ process.env.DEPENDABOT_AUTO_MERGE_INTERVAL_MIN || "10",
1464
+ );
1465
+ // Merge method: squash (default), merge, rebase
1466
+ const dependabotMergeMethod = String(
1467
+ process.env.DEPENDABOT_MERGE_METHOD ||
1468
+ configData.dependabotMergeMethod ||
1469
+ "squash",
1470
+ ).toLowerCase();
1471
+ // PR authors to auto-merge (comma-separated). Default: dependabot[bot]
1472
+ const dependabotAuthors = String(
1473
+ process.env.DEPENDABOT_AUTHORS ||
1474
+ configData.dependabotAuthors ||
1475
+ "dependabot[bot],app/dependabot",
1476
+ )
1477
+ .split(",")
1478
+ .map((a) => a.trim())
1479
+ .filter(Boolean);
1480
+
1481
+ // ── Status file ──────────────────────────────────────────
1482
+ const cacheDir = resolve(
1483
+ repoRoot,
1484
+ configData.cacheDir || selectedRepository?.cacheDir || ".cache",
1485
+ );
1486
+ // Default matches ve-orchestrator.ps1's $script:StatusStatePath
1487
+ const statusPath =
1488
+ process.env.STATUS_FILE ||
1489
+ configData.statusPath ||
1490
+ selectedRepository?.statusPath ||
1491
+ resolve(cacheDir, "ve-orchestrator-status.json");
1492
+ const lockBase =
1493
+ configData.telegramPollLockPath ||
1494
+ selectedRepository?.telegramPollLockPath ||
1495
+ resolve(cacheDir, "telegram-getupdates.lock");
1496
+ const telegramPollLockPath = lockBase.endsWith(".lock")
1497
+ ? resolve(lockBase)
1498
+ : resolve(lockBase, "telegram-getupdates.lock");
1499
+
1500
+ // ── Executors ────────────────────────────────────────────
1501
+ const executorConfig = loadExecutorConfig(configDir, configData);
1502
+ const scheduler = new ExecutorScheduler(executorConfig);
1503
+
1504
+ // ── Agent prompts ────────────────────────────────────────
1505
+ ensurePromptWorkspaceGitIgnore(repoRoot);
1506
+ ensureAgentPromptWorkspace(repoRoot);
1507
+ const agentPrompts = loadAgentPrompts(configDir, repoRoot, configData);
1508
+ const agentPromptSources = agentPrompts._sources || {};
1509
+ delete agentPrompts._sources;
1510
+ const agentPromptCatalog = getAgentPromptDefinitions();
1511
+
1512
+ // ── First-run detection ──────────────────────────────────
1513
+ const isFirstRun = !hasSetupMarkers(configDir);
1514
+
1515
+ const config = {
1516
+ // Identity
1517
+ projectName,
1518
+ mode,
1519
+ repoSlug,
1520
+ repoUrlBase,
1521
+ repoRoot,
1522
+ configDir,
1523
+ envPaths,
1524
+
1525
+ // Orchestrator
1526
+ scriptPath,
1527
+ scriptArgs,
1528
+ restartDelayMs,
1529
+ maxRestarts,
1530
+
1531
+ // Logging
1532
+ logDir,
1533
+ logMaxSizeMb,
1534
+ logCleanupIntervalMin,
1535
+
1536
+ // Agent SDK
1537
+ agentSdk,
1538
+
1539
+ // Feature flags
1540
+ watchEnabled,
1541
+ watchPath,
1542
+ echoLogs,
1543
+ autoFixEnabled,
1544
+ interactiveShellEnabled,
1545
+ preflightEnabled,
1546
+ preflightRetryMs,
1547
+ codexEnabled,
1548
+ agentPoolEnabled,
1549
+ primaryAgent,
1550
+ primaryAgentEnabled,
1551
+
1552
+ // Internal Executor
1553
+ internalExecutor,
1554
+ executorMode: internalExecutor.mode,
1555
+ kanban,
1556
+ githubProjectSync,
1557
+ jira,
1558
+ projectRequirements,
1559
+
1560
+ // Merge Strategy
1561
+ codexAnalyzeMergeStrategy:
1562
+ codexEnabled &&
1563
+ (process.env.CODEX_ANALYZE_MERGE_STRATEGY || "").toLowerCase() !==
1564
+ "false",
1565
+ mergeStrategyTimeoutMs:
1566
+ parseInt(process.env.MERGE_STRATEGY_TIMEOUT_MS, 10) || 10 * 60 * 1000,
1567
+
1568
+ // Autofix mode hint (informational — actual detection uses isDevMode())
1569
+ autofixMode: process.env.AUTOFIX_MODE || "auto",
1570
+
1571
+ // Vibe-Kanban
1572
+ vkRecoveryPort,
1573
+ vkRecoveryHost,
1574
+ vkEndpointUrl,
1575
+ vkPublicUrl,
1576
+ vkTaskUrlTemplate,
1577
+ vkRecoveryCooldownMin,
1578
+ vkRuntimeRequired,
1579
+ vkSpawnEnabled,
1580
+ vkEnsureIntervalMs,
1581
+
1582
+ // Telegram
1583
+ telegramToken,
1584
+ telegramChatId,
1585
+ telegramIntervalMin,
1586
+ telegramCommandPollTimeoutSec,
1587
+ telegramCommandConcurrency,
1588
+ telegramCommandMaxBatch,
1589
+ telegramBotEnabled,
1590
+ telegramCommandEnabled,
1591
+ telegramVerbosity,
1592
+
1593
+ // Task Planner
1594
+ plannerMode,
1595
+ plannerPerCapitaThreshold,
1596
+ plannerIdleSlotThreshold,
1597
+ plannerDedupHours,
1598
+ plannerDedupMs,
1599
+
1600
+ // GitHub Reconciler
1601
+ githubReconcile: {
1602
+ enabled: ghReconcileEnabled,
1603
+ intervalMs: ghReconcileIntervalMs,
1604
+ mergedLookbackHours: ghReconcileMergedLookbackHours,
1605
+ trackingLabels: ghReconcileTrackingLabels,
1606
+ },
1607
+
1608
+ // Dependabot Auto-Merge
1609
+ dependabotAutoMerge,
1610
+ dependabotAutoMergeIntervalMin,
1611
+ dependabotMergeMethod,
1612
+ dependabotAuthors,
1613
+
1614
+ // Branch Routing
1615
+ branchRouting,
1616
+
1617
+ // Fleet Coordination
1618
+ fleet,
1619
+
1620
+ // Paths
1621
+ statusPath,
1622
+ telegramPollLockPath,
1623
+ cacheDir,
1624
+
1625
+ // Executors
1626
+ executorConfig,
1627
+ scheduler,
1628
+
1629
+ // Multi-repo
1630
+ repositories,
1631
+ selectedRepository,
1632
+
1633
+ // Agent prompts
1634
+ agentPrompts,
1635
+ agentPromptSources,
1636
+ agentPromptCatalog,
1637
+
1638
+ // First run
1639
+ isFirstRun,
1640
+ };
1641
+
1642
+ return Object.freeze(config);
1643
+ }
1644
+
1645
+ // ── Helpers ──────────────────────────────────────────────────────────────────
1646
+
1647
+ function detectProjectName(configDir, repoRoot) {
1648
+ // Try package.json in repo root
1649
+ const pkgPath = resolve(repoRoot, "package.json");
1650
+ if (existsSync(pkgPath)) {
1651
+ try {
1652
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1653
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
1654
+ } catch {
1655
+ /* skip */
1656
+ }
1657
+ }
1658
+ // Fallback to directory name
1659
+ return basename(repoRoot);
1660
+ }
1661
+
1662
+ function findOrchestratorScript(configDir, repoRoot) {
1663
+ const shellModeEnv = String(process.env.BOSUN_SHELL_MODE || "")
1664
+ .trim()
1665
+ .toLowerCase();
1666
+ const shellModeRequested = ["1", "true", "yes", "on"].includes(shellModeEnv);
1667
+ const orchestratorEnv = String(process.env.ORCHESTRATOR_SCRIPT || "")
1668
+ .trim()
1669
+ .toLowerCase();
1670
+ const preferShellScript =
1671
+ shellModeRequested ||
1672
+ orchestratorEnv.endsWith(".sh") ||
1673
+ (process.platform !== "win32" && !orchestratorEnv.endsWith(".ps1"));
1674
+
1675
+ const shCandidates = [
1676
+ resolve(configDir, "ve-orchestrator.sh"),
1677
+ resolve(configDir, "orchestrator.sh"),
1678
+ resolve(configDir, "..", "ve-orchestrator.sh"),
1679
+ resolve(configDir, "..", "orchestrator.sh"),
1680
+ resolve(repoRoot, "scripts", "ve-orchestrator.sh"),
1681
+ resolve(repoRoot, "scripts", "orchestrator.sh"),
1682
+ resolve(repoRoot, "ve-orchestrator.sh"),
1683
+ resolve(repoRoot, "orchestrator.sh"),
1684
+ resolve(process.cwd(), "ve-orchestrator.sh"),
1685
+ resolve(process.cwd(), "orchestrator.sh"),
1686
+ resolve(process.cwd(), "scripts", "ve-orchestrator.sh"),
1687
+ ];
1688
+
1689
+ const psCandidates = [
1690
+ resolve(configDir, "ve-orchestrator.ps1"),
1691
+ resolve(configDir, "orchestrator.ps1"),
1692
+ resolve(configDir, "..", "ve-orchestrator.ps1"),
1693
+ resolve(configDir, "..", "orchestrator.ps1"),
1694
+ resolve(repoRoot, "scripts", "ve-orchestrator.ps1"),
1695
+ resolve(repoRoot, "scripts", "orchestrator.ps1"),
1696
+ resolve(repoRoot, "ve-orchestrator.ps1"),
1697
+ resolve(repoRoot, "orchestrator.ps1"),
1698
+ resolve(process.cwd(), "ve-orchestrator.ps1"),
1699
+ resolve(process.cwd(), "orchestrator.ps1"),
1700
+ resolve(process.cwd(), "scripts", "ve-orchestrator.ps1"),
1701
+ ];
1702
+
1703
+ const candidates = preferShellScript
1704
+ ? [...shCandidates, ...psCandidates]
1705
+ : [...psCandidates, ...shCandidates];
1706
+ for (const p of candidates) {
1707
+ if (existsSync(p)) return p;
1708
+ }
1709
+ return preferShellScript
1710
+ ? resolve(configDir, "..", "ve-orchestrator.sh")
1711
+ : resolve(configDir, "..", "ve-orchestrator.ps1");
1712
+ }
1713
+
1714
+ // ── Exports ──────────────────────────────────────────────────────────────────
1715
+
1716
+ export {
1717
+ ExecutorScheduler,
1718
+ loadExecutorConfig,
1719
+ loadRepoConfig,
1720
+ loadAgentPrompts,
1721
+ parseEnvBoolean,
1722
+ getAgentPromptDefinitions,
1723
+ };
1724
+ export default loadConfig;