bosun 0.31.6 → 0.31.8

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.
package/.env.example CHANGED
@@ -244,8 +244,8 @@ TELEGRAM_MINIAPP_ENABLED=false
244
244
 
245
245
  # ─── Executor Configuration ──────────────────────────────────────────────────
246
246
  # Define AI executors that work on tasks.
247
- # Format: EXECUTOR_TYPE:VARIANT:WEIGHT,EXECUTOR_TYPE:VARIANT:WEIGHT
248
- # Example: COPILOT:CLAUDE_OPUS_4_6:50,CODEX:DEFAULT:50
247
+ # Format: EXECUTOR_TYPE:VARIANT:WEIGHT[:MODEL|MODEL],EXECUTOR_TYPE:VARIANT:WEIGHT[:MODEL|MODEL]
248
+ # Example: COPILOT:CLAUDE_OPUS_4_6:50:claude-opus-4.6,CODEX:DEFAULT:50:gpt-5.2-codex|gpt-5.1-codex-mini
249
249
  # For full config, use bosun.config.json instead.
250
250
  # EXECUTORS=CODEX:DEFAULT:100
251
251
 
@@ -345,31 +345,42 @@ TELEGRAM_MINIAPP_ENABLED=false
345
345
  # OAuth Client Secret (only needed for callback-based OAuth, not for Device Flow):
346
346
  # BOSUN_GITHUB_CLIENT_SECRET=
347
347
  #
348
- # Webhook secret (set this in App settings Webhook, and keep in sync):
348
+ # Webhook secret (VirtEngine relay signs forwarded events with this leave blank
349
+ # until VirtEngine’s relay server is live; Bosun polls GitHub API in the meantime):
349
350
  # BOSUN_GITHUB_WEBHOOK_SECRET=
350
351
  #
351
352
  # Path to the PEM private key downloaded from App settings → Generate a private key:
352
353
  # BOSUN_GITHUB_PRIVATE_KEY_PATH=/path/to/bosun-botswain.pem
353
354
  #
355
+ # ─── GitHub App Settings (enable all three in https://github.com/settings/apps/bosun-botswain) ────
356
+ # ✅ Callback URL → http://127.0.0.1:54317/github/callback (set this FIRST, then Save)
357
+ # ✅ "Request user authorization (OAuth) during installation" → ON
358
+ # GitHub does OAuth at install time, redirecting to the Callback URL with
359
+ # installation_id + setup_action=install. Setup URL is DISABLED — that's fine.
360
+ # ✅ "Enable Device Flow" → ON (only available AFTER Callback URL is saved)
361
+ # Allows CLI/terminal auth without a public URL (like VS Code / Roo Code)
362
+ # ✕ Setup URL → leave BLANK (GitHub disables this field when OAuth-at-install is ON)
363
+ # ✕ "Redirect on update" → leave OFF (disabled alongside Setup URL)
364
+ #
354
365
  # ─── Authentication Method ───────────────────────────────────────────────
355
366
  # RECOMMENDED: Device Flow (like VS Code / Roo Code — no public URL needed!)
356
367
  # 1. Set BOSUN_GITHUB_CLIENT_ID above
357
- # 2. Enable "Device Flow" in GitHub App settings (Settings Optional features)
358
- # 3. Go to Settings → GitHub in the Bosun UI and click "Sign in with GitHub"
359
- # 4. That's it — no callback URL, no tunnel URL, no client secret needed
368
+ # 2. Enable Device Flow in GitHub App settings (only clickable after Callback URL is saved)
369
+ # 3. Go to Settings → GitHub in the Bosun UI and click Sign in with GitHub
370
+ # 4. Thats it — no webhook URL, no tunnel, no public server needed
360
371
  #
361
- # ALTERNATIVE: OAuth Callback (requires a stable public URL)
372
+ # ALTERNATIVE: OAuth Callback
362
373
  # Set BOSUN_GITHUB_CLIENT_ID + BOSUN_GITHUB_CLIENT_SECRET
363
- # Register callback URL in App settings:
364
- # https://<your-bosun-public-url>/api/github/callback
374
+ # Register callback URL: http://127.0.0.1:54317/github/callback
365
375
  #
366
- # WEBHOOKS (optional — for real-time PR/issue sync):
367
- # Register webhook URL in App settings:
368
- # https://<your-bosun-public-url>/api/webhooks/github/app
369
- # Webhooks require a stable public URL. Without them, Bosun polls instead.
376
+ # NOTE on webhooks:
377
+ # Real-time GitHub events (PR comments, issue mentions) are received via
378
+ # VirtEngine’s relay server and forwarded to your Bosun instance.
379
+ # Until the relay is live, Bosun polls the GitHub API every few minutes instead.
380
+ # Users do NOT need to configure a webhook URL or run any tunnel.
370
381
  #
371
382
  # Leave BOSUN_GITHUB_APP_ID unset to disable co-author trailer injection.
372
- # BOSUN_GITHUB_APP_ID=
383
+ # (App ID and Client ID are already filled in above — no need to set them again.)
373
384
 
374
385
  # ─── Kanban Backend ──────────────────────────────────────────────────────────
375
386
  # Task-board backend:
@@ -897,6 +908,12 @@ COPILOT_CLOUD_DISABLED=true
897
908
  # Task planner status stream interval (milliseconds). Default: 1800000 (30 min)
898
909
  # DEVMODE_TASK_PLANNER_STATUS_INTERVAL_MS=1800000
899
910
 
911
+ # ─── Trigger-Based Task System ───────────────────────────────────────────────
912
+ # Enable configurable trigger templates (disabled by default for safety).
913
+ # Built-in templates ship disabled: task-planner, daily-review-digest,
914
+ # stale-task-followup. Configure/enable in bosun.config.json under triggerSystem.
915
+ # TASK_TRIGGER_SYSTEM_ENABLED=false
916
+
900
917
  # ─── GitHub Issue Reconciler ─────────────────────────────────────────────────
901
918
  # Periodically reconciles open GitHub issues against open/merged PRs.
902
919
  # Hybrid close policy:
package/bosun.schema.json CHANGED
@@ -88,7 +88,10 @@
88
88
  "description": "Map internal statuses to project board column names",
89
89
  "properties": {
90
90
  "todo": { "type": "string", "default": "Todo" },
91
- "inprogress": { "type": "string", "default": "In Progress" },
91
+ "inprogress": {
92
+ "type": "string",
93
+ "default": "In Progress"
94
+ },
92
95
  "inreview": { "type": "string", "default": "In Review" },
93
96
  "done": { "type": "string", "default": "Done" },
94
97
  "cancelled": { "type": "string", "default": "Cancelled" }
@@ -221,6 +224,81 @@
221
224
  "type": "string",
222
225
  "enum": ["codex-sdk", "kanban", "disabled"]
223
226
  },
227
+ "triggerSystem": {
228
+ "type": "object",
229
+ "additionalProperties": true,
230
+ "properties": {
231
+ "enabled": { "type": "boolean", "default": false },
232
+ "templates": {
233
+ "type": "array",
234
+ "items": {
235
+ "type": "object",
236
+ "additionalProperties": true,
237
+ "properties": {
238
+ "id": { "type": "string" },
239
+ "name": { "type": "string" },
240
+ "description": { "type": "string" },
241
+ "enabled": { "type": "boolean", "default": false },
242
+ "action": {
243
+ "type": "string",
244
+ "enum": ["task-planner", "create-task"]
245
+ },
246
+ "minIntervalMinutes": { "type": "number", "minimum": 1 },
247
+ "trigger": {
248
+ "type": "object",
249
+ "additionalProperties": true,
250
+ "properties": {
251
+ "anyOf": {
252
+ "type": "array",
253
+ "items": {
254
+ "type": "object",
255
+ "additionalProperties": true,
256
+ "properties": {
257
+ "kind": {
258
+ "type": "string",
259
+ "enum": ["metric", "interval"]
260
+ },
261
+ "metric": { "type": "string" },
262
+ "operator": {
263
+ "type": "string",
264
+ "enum": ["lt", "lte", "gt", "gte", "eq", "neq"]
265
+ },
266
+ "value": { "type": ["number", "string", "boolean"] },
267
+ "minutes": { "type": "number", "minimum": 1 }
268
+ }
269
+ }
270
+ }
271
+ }
272
+ },
273
+ "config": {
274
+ "type": "object",
275
+ "additionalProperties": true,
276
+ "properties": {
277
+ "plannerMode": {
278
+ "type": "string",
279
+ "enum": ["codex-sdk", "kanban", "disabled"]
280
+ },
281
+ "defaultTaskCount": { "type": "number", "minimum": 1 },
282
+ "title": { "type": "string" },
283
+ "description": { "type": "string" },
284
+ "priority": { "type": "string" },
285
+ "executor": { "type": "string" },
286
+ "model": { "type": "string" }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ },
292
+ "defaults": {
293
+ "type": "object",
294
+ "additionalProperties": false,
295
+ "properties": {
296
+ "executor": { "type": "string" },
297
+ "model": { "type": "string" }
298
+ }
299
+ }
300
+ }
301
+ },
224
302
  "activeWorkspace": {
225
303
  "type": "string",
226
304
  "description": "ID of the currently active workspace"
@@ -249,11 +327,23 @@
249
327
  "additionalProperties": true,
250
328
  "required": ["name"],
251
329
  "properties": {
252
- "name": { "type": "string", "description": "Repository directory name" },
330
+ "name": {
331
+ "type": "string",
332
+ "description": "Repository directory name"
333
+ },
253
334
  "url": { "type": "string", "description": "Git clone URL" },
254
- "slug": { "type": "string", "description": "GitHub slug (org/repo)" },
255
- "primary": { "type": "boolean", "description": "Whether this is the primary repo" },
256
- "branch": { "type": "string", "description": "Default branch to track" }
335
+ "slug": {
336
+ "type": "string",
337
+ "description": "GitHub slug (org/repo)"
338
+ },
339
+ "primary": {
340
+ "type": "boolean",
341
+ "description": "Whether this is the primary repo"
342
+ },
343
+ "branch": {
344
+ "type": "string",
345
+ "description": "Default branch to track"
346
+ }
257
347
  }
258
348
  }
259
349
  },
@@ -399,6 +489,10 @@
399
489
  "name": { "type": "string" },
400
490
  "executor": { "type": "string" },
401
491
  "variant": { "type": "string" },
492
+ "models": {
493
+ "type": "array",
494
+ "items": { "type": "string" }
495
+ },
402
496
  "weight": { "type": "number" },
403
497
  "role": { "type": "string" },
404
498
  "enabled": { "type": "boolean" }
package/config-doctor.mjs CHANGED
@@ -340,6 +340,27 @@ export function runConfigDoctor(options = {}) {
340
340
  }
341
341
  }
342
342
 
343
+ if (backend === "jira") {
344
+ const missing = [];
345
+ if (!effective.JIRA_BASE_URL) missing.push("JIRA_BASE_URL");
346
+ if (!effective.JIRA_EMAIL) missing.push("JIRA_EMAIL");
347
+ if (!effective.JIRA_API_TOKEN) missing.push("JIRA_API_TOKEN");
348
+ const hasProjectKey = Boolean(
349
+ effective.JIRA_PROJECT_KEY || effective.KANBAN_PROJECT_ID,
350
+ );
351
+ if (!hasProjectKey) {
352
+ missing.push("JIRA_PROJECT_KEY (or KANBAN_PROJECT_ID)");
353
+ }
354
+ if (missing.length > 0) {
355
+ issues.errors.push({
356
+ code: "JIRA_BACKEND_REQUIRED",
357
+ message: `KANBAN_BACKEND=jira is missing required config: ${missing.join(", ")}`,
358
+ fix:
359
+ "Set required JIRA_* variables (and project key), or switch KANBAN_BACKEND=internal.",
360
+ });
361
+ }
362
+ }
363
+
343
364
  const vkNeeded = backend === "vk" || mode === "vk" || mode === "hybrid";
344
365
  if (vkNeeded) {
345
366
  const vkBaseUrl = effective.VK_BASE_URL || "";
package/config.mjs CHANGED
@@ -26,6 +26,10 @@ import {
26
26
  } from "./agent-prompts.mjs";
27
27
  import { resolveAgentRepoRoot } from "./repo-root.mjs";
28
28
  import { applyAllCompatibility } from "./compat.mjs";
29
+ import {
30
+ normalizeExecutorKey,
31
+ getModelsForExecutor,
32
+ } from "./task-complexity.mjs";
29
33
 
30
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
31
35
 
@@ -158,6 +162,80 @@ function loadDotEnvFile(envPath, options = {}) {
158
162
  }
159
163
  }
160
164
 
165
+ function readEnvValueFromFile(envPath, key) {
166
+ if (!envPath || !existsSync(envPath)) return undefined;
167
+ const lines = readFileSync(envPath, "utf8").split("\n");
168
+ let found;
169
+ for (const line of lines) {
170
+ const trimmed = line.trim();
171
+ if (!trimmed || trimmed.startsWith("#")) continue;
172
+ const eqIdx = trimmed.indexOf("=");
173
+ if (eqIdx === -1) continue;
174
+ const parsedKey = trimmed.slice(0, eqIdx).trim();
175
+ if (parsedKey !== key) continue;
176
+ let value = trimmed.slice(eqIdx + 1).trim();
177
+ if (
178
+ (value.startsWith('"') && value.endsWith('"')) ||
179
+ (value.startsWith("'") && value.endsWith("'"))
180
+ ) {
181
+ value = value.slice(1, -1);
182
+ }
183
+ found = value;
184
+ }
185
+ return found;
186
+ }
187
+
188
+ function resolveKanbanBackendSource({ envPaths = [], configFilePath, configData }) {
189
+ const key = "KANBAN_BACKEND";
190
+ let source = "default";
191
+ let sourcePath = null;
192
+
193
+ if (process.env[key] != null && String(process.env[key]).trim() !== "") {
194
+ let envFileMatch = null;
195
+ for (const envPath of envPaths) {
196
+ const value = readEnvValueFromFile(envPath, key);
197
+ if (value != null && String(value).trim() !== "") {
198
+ envFileMatch = envPath;
199
+ }
200
+ }
201
+ if (envFileMatch) {
202
+ source = "env-file";
203
+ sourcePath = envFileMatch;
204
+ } else {
205
+ source = "process-env";
206
+ }
207
+ } else if (configData?.kanban?.backend != null) {
208
+ source = "config-file";
209
+ sourcePath = configFilePath || null;
210
+ }
211
+
212
+ return Object.freeze({
213
+ key,
214
+ rawValue:
215
+ process.env[key] || configData?.kanban?.backend || "internal",
216
+ source,
217
+ sourcePath,
218
+ });
219
+ }
220
+
221
+ function validateKanbanBackendConfig({ kanbanBackend, kanban, jira }) {
222
+ if (kanbanBackend !== "jira") return;
223
+ const missing = [];
224
+ if (!jira?.baseUrl) missing.push("JIRA_BASE_URL");
225
+ if (!jira?.email) missing.push("JIRA_EMAIL");
226
+ if (!jira?.apiToken) missing.push("JIRA_API_TOKEN");
227
+ const hasProjectKey = Boolean(jira?.projectKey || kanban?.projectId);
228
+ if (!hasProjectKey) {
229
+ missing.push("JIRA_PROJECT_KEY (or KANBAN_PROJECT_ID)");
230
+ }
231
+ if (missing.length > 0) {
232
+ throw new Error(
233
+ `[config] KANBAN_BACKEND=jira requires ${missing.join(", ")}. ` +
234
+ `Either configure Jira credentials/project key or switch KANBAN_BACKEND=internal.`,
235
+ );
236
+ }
237
+ }
238
+
161
239
  function loadConfigFile(configDir) {
162
240
  for (const name of CONFIG_FILES) {
163
241
  const p = resolve(configDir, name);
@@ -268,6 +346,173 @@ function isEnvEnabled(value, defaultValue = false) {
268
346
  return parseEnvBoolean(value, defaultValue);
269
347
  }
270
348
 
349
+ function parseListValue(value) {
350
+ if (Array.isArray(value)) {
351
+ return value
352
+ .map((item) => String(item || "").trim())
353
+ .filter(Boolean);
354
+ }
355
+ return String(value || "")
356
+ .split(/[,|]/)
357
+ .map((item) => item.trim())
358
+ .filter(Boolean);
359
+ }
360
+
361
+ function normalizeExecutorModels(executor, models) {
362
+ const normalizedExecutor = normalizeExecutorKey(executor);
363
+ if (!normalizedExecutor) return [];
364
+ const input = parseListValue(models);
365
+ const known = new Set(getModelsForExecutor(normalizedExecutor));
366
+ if (input.length === 0) {
367
+ return [...known];
368
+ }
369
+ return input.filter((model) => known.has(model));
370
+ }
371
+
372
+ function normalizeExecutorEntry(entry, index = 0, total = 1) {
373
+ if (!entry || typeof entry !== "object") return null;
374
+ const executorType = String(entry.executor || "").trim().toUpperCase();
375
+ if (!executorType) return null;
376
+ const variant = String(entry.variant || "DEFAULT").trim() || "DEFAULT";
377
+ const normalized = normalizeExecutorKey(executorType) || "codex";
378
+ const weight = Number(entry.weight);
379
+ const safeWeight = Number.isFinite(weight) ? weight : Math.floor(100 / Math.max(1, total));
380
+ const role =
381
+ String(entry.role || "").trim() ||
382
+ (index === 0 ? "primary" : index === 1 ? "backup" : `executor-${index + 1}`);
383
+ const name =
384
+ String(entry.name || "").trim() ||
385
+ `${normalized}-${String(variant || "default").toLowerCase()}`;
386
+ const models = normalizeExecutorModels(executorType, entry.models);
387
+
388
+ return {
389
+ name,
390
+ executor: executorType,
391
+ variant,
392
+ weight: safeWeight,
393
+ role,
394
+ enabled: entry.enabled !== false,
395
+ models,
396
+ };
397
+ }
398
+
399
+ function buildDefaultTriggerTemplates({
400
+ plannerMode,
401
+ plannerPerCapitaThreshold,
402
+ plannerIdleSlotThreshold,
403
+ plannerDedupHours,
404
+ } = {}) {
405
+ return [
406
+ {
407
+ id: "task-planner",
408
+ name: "Task Planner",
409
+ description: "Create planning tasks when backlog/slot metrics indicate replenishment.",
410
+ enabled: false,
411
+ action: "task-planner",
412
+ trigger: {
413
+ anyOf: [
414
+ {
415
+ kind: "metric",
416
+ metric: "backlogPerCapita",
417
+ operator: "lt",
418
+ value: plannerPerCapitaThreshold,
419
+ },
420
+ {
421
+ kind: "metric",
422
+ metric: "idleSlots",
423
+ operator: "gte",
424
+ value: plannerIdleSlotThreshold,
425
+ },
426
+ {
427
+ kind: "metric",
428
+ metric: "backlogRemaining",
429
+ operator: "eq",
430
+ value: 0,
431
+ },
432
+ ],
433
+ },
434
+ minIntervalMinutes: Math.max(1, Number(plannerDedupHours || 6) * 60),
435
+ config: {
436
+ plannerMode,
437
+ defaultTaskCount: Number(process.env.TASK_PLANNER_DEFAULT_COUNT || "30"),
438
+ executor: "auto",
439
+ model: "auto",
440
+ },
441
+ },
442
+ {
443
+ id: "daily-review-digest",
444
+ name: "Daily Review Digest",
445
+ description: "Create a daily review task for fleet health and backlog quality.",
446
+ enabled: false,
447
+ action: "create-task",
448
+ trigger: {
449
+ anyOf: [
450
+ {
451
+ kind: "interval",
452
+ minutes: 24 * 60,
453
+ },
454
+ ],
455
+ },
456
+ minIntervalMinutes: 24 * 60,
457
+ config: {
458
+ title: "[m] Daily review digest",
459
+ description:
460
+ "Review active backlog, blocked tasks, and stale work. Capture next actions and priority adjustments.",
461
+ priority: "medium",
462
+ executor: "auto",
463
+ model: "auto",
464
+ },
465
+ },
466
+ {
467
+ id: "stale-task-followup",
468
+ name: "Stale Task Follow-up",
469
+ description: "Create a follow-up task when stale in-progress work accumulates.",
470
+ enabled: false,
471
+ action: "create-task",
472
+ trigger: {
473
+ anyOf: [
474
+ {
475
+ kind: "metric",
476
+ metric: "staleInProgressCount",
477
+ operator: "gte",
478
+ value: 1,
479
+ },
480
+ ],
481
+ },
482
+ minIntervalMinutes: 60,
483
+ config: {
484
+ title: "[m] Follow up stale in-progress tasks",
485
+ description:
486
+ "Audit stale in-progress tasks, unblock owners, or split work to recover flow.",
487
+ priority: "high",
488
+ staleHours: Number(process.env.STALE_TASK_AGE_HOURS || "24"),
489
+ executor: "auto",
490
+ model: "auto",
491
+ },
492
+ },
493
+ ];
494
+ }
495
+
496
+ function resolveTriggerSystemConfig(configData, defaults) {
497
+ const configTrigger =
498
+ configData && typeof configData.triggerSystem === "object"
499
+ ? configData.triggerSystem
500
+ : configData && typeof configData.triggers === "object"
501
+ ? configData.triggers
502
+ : {};
503
+ const templates = Array.isArray(configTrigger.templates)
504
+ ? configTrigger.templates
505
+ : defaults.templates;
506
+ return Object.freeze({
507
+ enabled: isEnvEnabled(
508
+ process.env.TASK_TRIGGER_SYSTEM_ENABLED ?? configTrigger.enabled,
509
+ false,
510
+ ),
511
+ templates,
512
+ defaults: defaults.defaults,
513
+ });
514
+ }
515
+
271
516
  // ── Git helpers ──────────────────────────────────────────────────────────────
272
517
 
273
518
  function detectRepoSlug(repoRoot = "") {
@@ -421,7 +666,7 @@ const DEFAULT_EXECUTORS = {
421
666
  };
422
667
 
423
668
  function parseExecutorsFromEnv() {
424
- // EXECUTORS=CODEX:DEFAULT:100
669
+ // EXECUTORS=CODEX:DEFAULT:100:gpt-5.2-codex|gpt-5.1-codex-mini
425
670
  const raw = process.env.EXECUTORS;
426
671
  if (!raw) return null;
427
672
  const entries = raw.split(",").map((e) => e.trim());
@@ -430,13 +675,16 @@ function parseExecutorsFromEnv() {
430
675
  for (let i = 0; i < entries.length; i++) {
431
676
  const parts = entries[i].split(":");
432
677
  if (parts.length < 2) continue;
678
+ const executorType = parts[0].toUpperCase();
679
+ const models = normalizeExecutorModels(executorType, parts[3] || "");
433
680
  executors.push({
434
681
  name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
435
- executor: parts[0].toUpperCase(),
682
+ executor: executorType,
436
683
  variant: parts[1],
437
684
  weight: parts[2] ? Number(parts[2]) : Math.floor(100 / entries.length),
438
685
  role: roles[i] || `executor-${i + 1}`,
439
686
  enabled: true,
687
+ models,
440
688
  });
441
689
  }
442
690
  return executors.length ? executors : null;
@@ -522,8 +770,11 @@ function loadExecutorConfig(configDir, configData) {
522
770
  }
523
771
  }
524
772
 
525
- const executors =
773
+ const baseExecutors =
526
774
  fromEnv || fromFile?.executors || DEFAULT_EXECUTORS.executors;
775
+ const executors = (Array.isArray(baseExecutors) ? baseExecutors : [])
776
+ .map((entry, index, arr) => normalizeExecutorEntry(entry, index, arr.length))
777
+ .filter(Boolean);
527
778
  const failover = fromFile?.failover || {
528
779
  strategy:
529
780
  process.env.FAILOVER_STRATEGY || DEFAULT_EXECUTORS.failover.strategy,
@@ -970,6 +1221,11 @@ export function loadConfig(argv = process.argv, options = {}) {
970
1221
  resolve(configDir, ".env"),
971
1222
  resolve(repoRoot, ".env"),
972
1223
  ].filter((p, i, arr) => arr.indexOf(p) === i);
1224
+ const kanbanSource = resolveKanbanBackendSource({
1225
+ envPaths,
1226
+ configFilePath: configFile.path,
1227
+ configData,
1228
+ });
973
1229
 
974
1230
  // ── Project identity ─────────────────────────────────────
975
1231
  const projectName =
@@ -1284,6 +1540,7 @@ export function loadConfig(argv = process.argv, options = {}) {
1284
1540
  "",
1285
1541
  }),
1286
1542
  });
1543
+ validateKanbanBackendConfig({ kanbanBackend, kanban, jira });
1287
1544
 
1288
1545
  const internalExecutorConfig = configData.internalExecutor || {};
1289
1546
  const projectRequirements = {
@@ -1483,6 +1740,23 @@ export function loadConfig(argv = process.argv, options = {}) {
1483
1740
  ? plannerDedupHours * 60 * 60 * 1000
1484
1741
  : 24 * 60 * 60 * 1000;
1485
1742
 
1743
+ const triggerSystemDefaults = Object.freeze({
1744
+ templates: buildDefaultTriggerTemplates({
1745
+ plannerMode,
1746
+ plannerPerCapitaThreshold,
1747
+ plannerIdleSlotThreshold,
1748
+ plannerDedupHours,
1749
+ }),
1750
+ defaults: Object.freeze({
1751
+ executor: "auto",
1752
+ model: "auto",
1753
+ }),
1754
+ });
1755
+ const triggerSystem = resolveTriggerSystemConfig(
1756
+ configData,
1757
+ triggerSystemDefaults,
1758
+ );
1759
+
1486
1760
  // ── GitHub Reconciler ───────────────────────────────────
1487
1761
  const ghReconcileEnabled = isEnvEnabled(
1488
1762
  process.env.GH_RECONCILE_ENABLED ?? configData.ghReconcileEnabled,
@@ -1706,6 +1980,7 @@ export function loadConfig(argv = process.argv, options = {}) {
1706
1980
  internalExecutor,
1707
1981
  executorMode: internalExecutor.mode,
1708
1982
  kanban,
1983
+ kanbanSource,
1709
1984
  githubProjectSync,
1710
1985
  jira,
1711
1986
  projectRequirements,
@@ -1749,6 +2024,7 @@ export function loadConfig(argv = process.argv, options = {}) {
1749
2024
  plannerIdleSlotThreshold,
1750
2025
  plannerDedupHours,
1751
2026
  plannerDedupMs,
2027
+ triggerSystem,
1752
2028
 
1753
2029
  // GitHub Reconciler
1754
2030
  githubReconcile: {
@@ -31,6 +31,10 @@ const containerTimeout = parseInt(
31
31
  process.env.CONTAINER_TIMEOUT_MS || "1800000",
32
32
  10,
33
33
  ); // 30 min default
34
+ const containerRuntimeCheckTimeout = Math.max(
35
+ 500,
36
+ parseInt(process.env.CONTAINER_RUNTIME_CHECK_TIMEOUT_MS || "3000", 10),
37
+ );
34
38
  const containerMaxOutput = parseInt(
35
39
  process.env.CONTAINER_MAX_OUTPUT_SIZE || "10485760",
36
40
  10,
@@ -84,11 +88,17 @@ export function checkContainerRuntime() {
84
88
  try {
85
89
  if (containerRuntime === "container") {
86
90
  // macOS Apple Container
87
- execSync("container system status", { stdio: "pipe" });
91
+ execSync("container system status", {
92
+ stdio: "pipe",
93
+ timeout: containerRuntimeCheckTimeout,
94
+ });
88
95
  return { available: true, runtime: "container", platform: "macos" };
89
96
  }
90
97
  // Docker or Podman
91
- execSync(`${containerRuntime} info`, { stdio: "pipe", timeout: 10000 });
98
+ execSync(`${containerRuntime} info`, {
99
+ stdio: "pipe",
100
+ timeout: containerRuntimeCheckTimeout,
101
+ });
92
102
  return {
93
103
  available: true,
94
104
  runtime: containerRuntime,
@@ -110,7 +120,10 @@ export function ensureContainerRuntime() {
110
120
  if (containerRuntime === "container") {
111
121
  // macOS Apple Container — may need explicit start
112
122
  try {
113
- execSync("container system status", { stdio: "pipe" });
123
+ execSync("container system status", {
124
+ stdio: "pipe",
125
+ timeout: containerRuntimeCheckTimeout,
126
+ });
114
127
  } catch {
115
128
  console.log("[container] Starting Apple Container system...");
116
129
  try {