@virtengine/openfleet 0.25.0

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