bosun 0.40.21 → 0.41.1

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 (80) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-custom-tools.mjs +23 -5
  4. package/agent/agent-event-bus.mjs +248 -6
  5. package/agent/agent-pool.mjs +131 -30
  6. package/agent/agent-work-analyzer.mjs +8 -16
  7. package/agent/primary-agent.mjs +81 -7
  8. package/agent/retry-queue.mjs +164 -0
  9. package/bench/swebench/bosun-swebench.mjs +5 -0
  10. package/bosun.config.example.json +25 -0
  11. package/bosun.schema.json +825 -183
  12. package/cli.mjs +267 -8
  13. package/config/config-doctor.mjs +51 -2
  14. package/config/config.mjs +232 -5
  15. package/github/github-auth-manager.mjs +70 -19
  16. package/infra/library-manager.mjs +894 -60
  17. package/infra/monitor.mjs +701 -69
  18. package/infra/runtime-accumulator.mjs +376 -84
  19. package/infra/session-tracker.mjs +95 -28
  20. package/infra/test-runtime.mjs +267 -0
  21. package/lib/codebase-audit.mjs +133 -18
  22. package/package.json +30 -8
  23. package/server/setup-web-server.mjs +29 -1
  24. package/server/ui-server.mjs +1571 -49
  25. package/setup.mjs +27 -24
  26. package/shell/codex-shell.mjs +34 -3
  27. package/shell/copilot-shell.mjs +50 -8
  28. package/task/msg-hub.mjs +193 -0
  29. package/task/pipeline.mjs +544 -0
  30. package/task/task-claims.mjs +6 -10
  31. package/task/task-cli.mjs +38 -2
  32. package/task/task-executor-pipeline.mjs +143 -0
  33. package/task/task-executor.mjs +36 -27
  34. package/telegram/get-telegram-chat-id.mjs +57 -47
  35. package/ui/components/chat-view.js +18 -1
  36. package/ui/components/workspace-switcher.js +321 -9
  37. package/ui/demo-defaults.js +17830 -10433
  38. package/ui/demo.html +9 -1
  39. package/ui/modules/router.js +1 -1
  40. package/ui/modules/settings-schema.js +2 -0
  41. package/ui/modules/state.js +54 -57
  42. package/ui/modules/voice-client-sdk.js +376 -37
  43. package/ui/modules/voice-client.js +173 -33
  44. package/ui/setup.html +68 -2
  45. package/ui/styles/components.css +571 -1
  46. package/ui/styles.css +201 -1
  47. package/ui/tabs/dashboard.js +74 -0
  48. package/ui/tabs/library.js +410 -55
  49. package/ui/tabs/logs.js +10 -0
  50. package/ui/tabs/settings.js +178 -99
  51. package/ui/tabs/tasks.js +1083 -507
  52. package/ui/tabs/telemetry.js +34 -0
  53. package/ui/tabs/workflow-canvas-utils.mjs +38 -1
  54. package/ui/tabs/workflows.js +1275 -402
  55. package/voice/voice-agents-sdk.mjs +2 -2
  56. package/voice/voice-relay.mjs +28 -20
  57. package/workflow/declarative-workflows.mjs +145 -0
  58. package/workflow/msg-hub.mjs +237 -0
  59. package/workflow/pipeline-workflows.mjs +287 -0
  60. package/workflow/pipeline.mjs +828 -315
  61. package/workflow/project-detection.mjs +559 -0
  62. package/workflow/workflow-cli.mjs +128 -0
  63. package/workflow/workflow-contract.mjs +433 -232
  64. package/workflow/workflow-engine.mjs +510 -47
  65. package/workflow/workflow-nodes/custom-loader.mjs +251 -0
  66. package/workflow/workflow-nodes.mjs +2024 -184
  67. package/workflow/workflow-templates.mjs +118 -24
  68. package/workflow-templates/agents.mjs +20 -20
  69. package/workflow-templates/bosun-native.mjs +212 -2
  70. package/workflow-templates/code-quality.mjs +20 -14
  71. package/workflow-templates/continuation-loop.mjs +339 -0
  72. package/workflow-templates/github.mjs +516 -40
  73. package/workflow-templates/planning.mjs +446 -17
  74. package/workflow-templates/reliability.mjs +65 -12
  75. package/workflow-templates/task-batch.mjs +27 -10
  76. package/workflow-templates/task-execution.mjs +752 -0
  77. package/workflow-templates/task-lifecycle.mjs +117 -14
  78. package/workspace/context-cache.mjs +66 -18
  79. package/workspace/workspace-manager.mjs +153 -1
  80. package/workflow-templates/issue-continuation.mjs +0 -243
package/cli.mjs CHANGED
@@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url";
28
28
  import { execFileSync, execSync, fork, spawn } from "node:child_process";
29
29
  import os from "node:os";
30
30
  import { createDaemonCrashTracker } from "./infra/daemon-restart-policy.mjs";
31
+ import { ensureTestRuntimeSandbox } from "./infra/test-runtime.mjs";
31
32
  import {
32
33
  applyAllCompatibility,
33
34
  detectLegacySetup,
@@ -76,12 +77,14 @@ function showHelp() {
76
77
  bosun [options]
77
78
 
78
79
  COMMANDS
79
- audit <command> [options] Codebase annotation audit workflows (scan/generate/warn/manifest/index/trim/conformity/migrate)
80
- --setup Launch the web-based setup wizard (default)
80
+ workflow list List declarative pipeline workflows
81
+ workflow run <name> Run a declarative pipeline workflow
82
+ --setup Launch the web-based setup wizard (default)
81
83
  --setup-terminal Run the legacy terminal setup wizard
82
84
  --where Show the resolved bosun config directory
83
85
  --doctor Validate bosun .env/config setup
84
86
  --tool-log <ID|list|prune> Retrieve/list/prune cached tool outputs
87
+ node:create <name> Scaffold a custom workflow node in custom-nodes/
85
88
  --context-index [mode] Run context index workflow (run|status|search)
86
89
  --context-index-query <text> Query text for context index search mode
87
90
  --context-index-limit <n> Max results for context index search (default: 25)
@@ -140,6 +143,12 @@ function showHelp() {
140
143
  --workspace-switch <id> Switch active workspace
141
144
  --workspace-add-repo Add repo to workspace (interactive)
142
145
  --workspace-health Run workspace health diagnostics
146
+ --workspace-pause <id> Pause a workspace (no new workflows)
147
+ --workspace-resume <id> Resume a paused workspace
148
+ --workspace-disable <id> Disable a workspace entirely
149
+ --workspace-status Show state summary of all workspaces
150
+ --workspace-executors <id> Show/set executor config for workspace
151
+ [--max-concurrent N] [--pool shared|dedicated] [--weight N]
143
152
 
144
153
  TASK MANAGEMENT
145
154
  task list [--status s] [--json] List tasks with optional filters
@@ -152,6 +161,12 @@ function showHelp() {
152
161
 
153
162
  Run 'bosun task --help' for complete task CLI documentation and examples.
154
163
 
164
+ WORKFLOWS
165
+ workflow list List built-in and configured workflows
166
+ workflow run <name> Run a declarative fresh-context workflow
167
+
168
+ Run 'bosun workflow --help' for workflow CLI examples.
169
+
155
170
  VIBE-KANBAN
156
171
  --no-vk-spawn Don't auto-spawn Vibe-Kanban
157
172
  --vk-ensure-interval <ms> VK health check interval (default: 60000)
@@ -252,6 +267,9 @@ function resolveConfigDirForCli() {
252
267
  const repoLocalConfigDir = resolveRepoLocalBosunDir(repoRoot);
253
268
  if (repoLocalConfigDir) return repoLocalConfigDir;
254
269
 
270
+ const sandbox = ensureTestRuntimeSandbox();
271
+ if (sandbox?.configDir) return sandbox.configDir;
272
+
255
273
  const preferWindowsDirs =
256
274
  process.platform === "win32" && !isWslInteropRuntime();
257
275
  const baseDir = preferWindowsDirs
@@ -368,10 +386,21 @@ function uniqueResolvedPaths(paths) {
368
386
  return results;
369
387
  }
370
388
 
389
+ function getWorkspaceScopedCacheDirCandidate(repoRootPath) {
390
+ const bosunDir = process.env.BOSUN_DIR || resolveConfigDirForCli();
391
+ if (!bosunDir || !repoRootPath) return null;
392
+ const parts = String(repoRootPath).replace(/\\/g, "/").split("/").filter(Boolean);
393
+ const repoName = parts.at(-1);
394
+ const workspaceName = parts.at(-2);
395
+ if (!repoName || !workspaceName) return null;
396
+ return resolve(bosunDir, "workspaces", workspaceName, repoName, ".cache");
397
+ }
398
+
371
399
  function getRuntimeCacheDirCandidates(extraCacheDirs = []) {
372
400
  return uniqueResolvedPaths([
373
401
  ...extraCacheDirs,
374
402
  runtimeCacheDir,
403
+ getWorkspaceScopedCacheDirCandidate(runtimeRepoRoot),
375
404
  process.env.BOSUN_DIR ? resolve(process.env.BOSUN_DIR, ".cache") : null,
376
405
  resolve(__dirname, ".cache"),
377
406
  resolve(process.cwd(), ".cache"),
@@ -625,6 +654,42 @@ function findGhostDaemonPids() {
625
654
  }
626
655
  }
627
656
 
657
+ function findGhostSentinelPids() {
658
+ if (process.platform === "win32") {
659
+ try {
660
+ const out = execFileSync(
661
+ "powershell",
662
+ [
663
+ "-NoProfile",
664
+ "-Command",
665
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|electron)(\\.exe)?$' -and $_.CommandLine -match 'telegram-sentinel\\.mjs' } | Select-Object -ExpandProperty ProcessId",
666
+ ],
667
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
668
+ ).trim();
669
+ if (!out) return [];
670
+ return out
671
+ .split(/\r?\n/)
672
+ .map((s) => parseInt(String(s).trim(), 10))
673
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
674
+ } catch {
675
+ return [];
676
+ }
677
+ }
678
+ try {
679
+ const out = execFileSync(
680
+ "pgrep",
681
+ ["-f", "telegram-sentinel\\.mjs"],
682
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
683
+ ).trim();
684
+ return out
685
+ .split("\n")
686
+ .map((s) => parseInt(s.trim(), 10))
687
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
688
+ } catch {
689
+ return [];
690
+ }
691
+ }
692
+
628
693
  function writePidFile(pid) {
629
694
  try {
630
695
  mkdirSync(dirname(DAEMON_PID_FILE), { recursive: true });
@@ -852,11 +917,28 @@ function daemonStatus() {
852
917
  } else {
853
918
  // Check for ghost daemon-child processes (alive but no PID file)
854
919
  const ghosts = findGhostDaemonPids();
920
+ const ghostSentinels = findGhostSentinelPids();
855
921
  if (ghosts.length > 0) {
856
922
  console.log(` :alert: bosun daemon is NOT tracked (no PID file), but ${ghosts.length} ghost process(es) found: ${ghosts.join(", ")}`);
857
923
  console.log(` The daemon is likely running but its PID file was lost.`);
858
- console.log(` Run --stop-daemon to clean up, then --daemon to restart.`);
924
+ if (ghostSentinels.length > 0) {
925
+ console.log(` Ghost sentinel restart owner(s) detected: ${ghostSentinels.join(", ")}`);
926
+ }
927
+ console.log(` Run --terminate to stop restart owners, then --daemon to restart.`);
859
928
  } else {
929
+ const existingMonitorOwner = detectExistingMonitorLockOwner();
930
+ if (existingMonitorOwner) {
931
+ console.log(
932
+ ` bosun daemon is not running in daemon mode, but bosun monitor is active (PID ${existingMonitorOwner.pid}).`,
933
+ );
934
+ console.log(
935
+ ` Bosun is running in monitor mode with lock file ${existingMonitorOwner.pidFile}.`,
936
+ );
937
+ console.log(
938
+ ` Use 'bosun --terminate' to stop it, or 'bosun --daemon' only after it is fully stopped.`,
939
+ );
940
+ process.exit(0);
941
+ }
860
942
  // Broader scan: portal, monitor, ui-server, etc. (non-daemon bosun processes)
861
943
  const allPids = findAllBosunProcessPids();
862
944
  if (allPids.length > 0) {
@@ -1108,6 +1190,7 @@ async function terminateBosun() {
1108
1190
  ]).map((pidFile) => readAlivePid(pidFile)),
1109
1191
  readSentinelPid(),
1110
1192
  ].filter((pid) => Number.isFinite(pid) && pid > 0);
1193
+ const sentinelGhostPids = findGhostSentinelPids();
1111
1194
  const manualStopHoldMs =
1112
1195
  Math.max(
1113
1196
  0,
@@ -1119,9 +1202,17 @@ async function terminateBosun() {
1119
1202
  ...daemonPids,
1120
1203
  ...monitorPids,
1121
1204
  ...sentinelPids,
1205
+ ...sentinelGhostPids,
1206
+ ...ghosts,
1122
1207
  ]);
1123
1208
  const restartOwnerPids = Array.from(
1124
- new Set([...ancestorPids, ...sentinelPids, ...daemonPids, ...ghosts]),
1209
+ new Set([
1210
+ ...ancestorPids,
1211
+ ...sentinelPids,
1212
+ ...sentinelGhostPids,
1213
+ ...daemonPids,
1214
+ ...ghosts,
1215
+ ]),
1125
1216
  ).filter((pid) => pid !== process.pid);
1126
1217
  const tracked = [...restartOwnerPids, ...monitorPids];
1127
1218
  const trackedPids = Array.from(new Set([...tracked, ...ghosts])).filter(
@@ -1251,7 +1342,21 @@ async function main() {
1251
1342
  process.exit(0);
1252
1343
  }
1253
1344
 
1254
- // Handle 'audit' subcommand before --help so command-specific help works.
1345
+ const workflowFlagIndex = args.indexOf("--workflow");
1346
+ const workflowCommandIndex =
1347
+ args[0] === "workflow"
1348
+ ? 0
1349
+ : args[0]?.startsWith("--")
1350
+ ? args.indexOf("workflow")
1351
+ : -1;
1352
+ if (workflowCommandIndex >= 0 || workflowFlagIndex >= 0) {
1353
+ const { runWorkflowCli } = await import("./workflow/workflow-cli.mjs");
1354
+ const commandStartIndex = workflowCommandIndex >= 0 ? workflowCommandIndex : workflowFlagIndex;
1355
+ const workflowArgs = args.slice(commandStartIndex + 1);
1356
+ await runWorkflowCli(workflowArgs);
1357
+ process.exit(0);
1358
+ }
1359
+
1255
1360
  const auditFlagIndex = args.indexOf("--audit");
1256
1361
  const auditCommandIndex =
1257
1362
  args[0] === "audit"
@@ -1263,8 +1368,27 @@ async function main() {
1263
1368
  const { runAuditCli } = await import("./lib/codebase-audit.mjs");
1264
1369
  const commandStartIndex = auditCommandIndex >= 0 ? auditCommandIndex : auditFlagIndex;
1265
1370
  const auditArgs = args.slice(commandStartIndex + 1);
1266
- const result = await runAuditCli(auditArgs);
1267
- process.exit(Number.isInteger(result?.exitCode) ? result.exitCode : 0);
1371
+ await runAuditCli(auditArgs);
1372
+ process.exit(0);
1373
+ }
1374
+
1375
+ if (args[0] === "node:create" || (args[0] === "node" && args[1] === "create")) {
1376
+ const name = args[0] === "node:create" ? args[1] : args[2];
1377
+ if (!name) {
1378
+ console.error("Usage: bosun node:create <name>");
1379
+ process.exit(1);
1380
+ }
1381
+ const { scaffoldCustomNodeFile } = await import("./workflow/workflow-nodes.mjs");
1382
+ try {
1383
+ const result = scaffoldCustomNodeFile(name, { repoRoot: runtimeRepoRoot });
1384
+ console.log(`\n ✓ Created custom node \"${result.type}\"`);
1385
+ console.log(` File: ${result.filePath}`);
1386
+ console.log("");
1387
+ } catch (err) {
1388
+ console.error(` Error: ${err.message}`);
1389
+ process.exit(1);
1390
+ }
1391
+ process.exit(0);
1268
1392
  }
1269
1393
 
1270
1394
  // Handle --help
@@ -1742,7 +1866,11 @@ async function main() {
1742
1866
  console.log("\n Workspaces:");
1743
1867
  for (const ws of workspaces) {
1744
1868
  const marker = ws.id === active?.id ? " ← active" : "";
1745
- console.log(` ${ws.name} (${ws.id})${marker}`);
1869
+ const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
1870
+ const stateLabel = ws.state !== "active" ? ` [${ws.state}]` : "";
1871
+ console.log(` ${stateIcon} ${ws.name} (${ws.id})${stateLabel}${marker}`);
1872
+ const ex = ws.executors;
1873
+ console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
1746
1874
  for (const repo of ws.repos || []) {
1747
1875
  const primary = repo.primary ? " [primary]" : "";
1748
1876
  const exists = repo.exists ? "✓" : "✗";
@@ -1831,6 +1959,137 @@ async function main() {
1831
1959
  process.exit(result.ok ? 0 : 1);
1832
1960
  }
1833
1961
 
1962
+ // Handle --workspace-pause
1963
+ if (args.includes("--workspace-pause") || args.includes("workspace-pause")) {
1964
+ const { pauseWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1965
+ const configDirArg = getArgValue("--config-dir");
1966
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1967
+ const wsId = getArgValue("--workspace-pause") || getArgValue("workspace-pause");
1968
+ if (!wsId) {
1969
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-pause <id>");
1970
+ process.exit(1);
1971
+ }
1972
+ try {
1973
+ pauseWorkspace(configDir, wsId);
1974
+ const ws = getWorkspace(configDir, wsId);
1975
+ console.log(`\n ⏸ Workspace "${ws?.name || wsId}" paused — no new workflows will start\n`);
1976
+ } catch (err) {
1977
+ console.error(` Error: ${err.message}`);
1978
+ process.exit(1);
1979
+ }
1980
+ process.exit(0);
1981
+ }
1982
+
1983
+ // Handle --workspace-resume
1984
+ if (args.includes("--workspace-resume") || args.includes("workspace-resume")) {
1985
+ const { resumeWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1986
+ const configDirArg = getArgValue("--config-dir");
1987
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1988
+ const wsId = getArgValue("--workspace-resume") || getArgValue("workspace-resume");
1989
+ if (!wsId) {
1990
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-resume <id>");
1991
+ process.exit(1);
1992
+ }
1993
+ try {
1994
+ resumeWorkspace(configDir, wsId);
1995
+ const ws = getWorkspace(configDir, wsId);
1996
+ console.log(`\n ▶ Workspace "${ws?.name || wsId}" resumed — workflows will trigger normally\n`);
1997
+ } catch (err) {
1998
+ console.error(` Error: ${err.message}`);
1999
+ process.exit(1);
2000
+ }
2001
+ process.exit(0);
2002
+ }
2003
+
2004
+ // Handle --workspace-disable
2005
+ if (args.includes("--workspace-disable") || args.includes("workspace-disable")) {
2006
+ const { disableWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
2007
+ const configDirArg = getArgValue("--config-dir");
2008
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
2009
+ const wsId = getArgValue("--workspace-disable") || getArgValue("workspace-disable");
2010
+ if (!wsId) {
2011
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-disable <id>");
2012
+ process.exit(1);
2013
+ }
2014
+ try {
2015
+ disableWorkspace(configDir, wsId);
2016
+ const ws = getWorkspace(configDir, wsId);
2017
+ console.log(`\n ⏹ Workspace "${ws?.name || wsId}" disabled — no workflows, no executors\n`);
2018
+ } catch (err) {
2019
+ console.error(` Error: ${err.message}`);
2020
+ process.exit(1);
2021
+ }
2022
+ process.exit(0);
2023
+ }
2024
+
2025
+ // Handle --workspace-status
2026
+ if (args.includes("--workspace-status") || args.includes("workspace-status")) {
2027
+ const { getWorkspaceStateSummary } = await import("./workspace/workspace-manager.mjs");
2028
+ const configDirArg = getArgValue("--config-dir");
2029
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
2030
+ const summary = getWorkspaceStateSummary(configDir);
2031
+ if (summary.length === 0) {
2032
+ console.log("\n No workspaces configured.\n");
2033
+ } else {
2034
+ console.log("\n Workspace Status:");
2035
+ for (const ws of summary) {
2036
+ const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
2037
+ const current = ws.isCurrent ? " ← current" : "";
2038
+ console.log(` ${stateIcon} ${ws.name} (${ws.id}) — ${ws.state}${current}`);
2039
+ const ex = ws.executors;
2040
+ console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
2041
+ if (ws.disabledWorkflows.length > 0) {
2042
+ console.log(` disabled workflows: ${ws.disabledWorkflows.join(", ")}`);
2043
+ }
2044
+ if (ws.enabledWorkflows.length > 0) {
2045
+ console.log(` enabled workflows: ${ws.enabledWorkflows.join(", ")}`);
2046
+ }
2047
+ }
2048
+ console.log("");
2049
+ }
2050
+ process.exit(0);
2051
+ }
2052
+
2053
+ // Handle --workspace-executors
2054
+ if (args.includes("--workspace-executors") || args.includes("workspace-executors")) {
2055
+ const { setWorkspaceExecutors, getWorkspace } = await import("./workspace/workspace-manager.mjs");
2056
+ const configDirArg = getArgValue("--config-dir");
2057
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
2058
+ const wsId = getArgValue("--workspace-executors") || getArgValue("workspace-executors");
2059
+ if (!wsId) {
2060
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-executors <id> [--max-concurrent N] [--pool shared|dedicated] [--weight N]");
2061
+ process.exit(1);
2062
+ }
2063
+ const maxConcurrent = getArgValue("--max-concurrent");
2064
+ const pool = getArgValue("--pool");
2065
+ const weight = getArgValue("--weight");
2066
+ const hasUpdate = maxConcurrent || pool || weight;
2067
+ if (hasUpdate) {
2068
+ try {
2069
+ const opts = {};
2070
+ if (maxConcurrent) opts.maxConcurrent = Number(maxConcurrent);
2071
+ if (pool) opts.pool = pool;
2072
+ if (weight) opts.weight = Number(weight);
2073
+ const result = setWorkspaceExecutors(configDir, wsId, opts);
2074
+ console.log(`\n ✓ Executor config updated for "${wsId}":`, JSON.stringify(result), "\n");
2075
+ } catch (err) {
2076
+ console.error(` Error: ${err.message}`);
2077
+ process.exit(1);
2078
+ }
2079
+ } else {
2080
+ const ws = getWorkspace(configDir, wsId);
2081
+ if (!ws) {
2082
+ console.error(` Error: workspace "${wsId}" not found`);
2083
+ process.exit(1);
2084
+ }
2085
+ console.log(`\n Executor config for "${ws.name}":`);
2086
+ console.log(` maxConcurrent: ${ws.executors.maxConcurrent}`);
2087
+ console.log(` pool: ${ws.executors.pool}`);
2088
+ console.log(` weight: ${ws.executors.weight}\n`);
2089
+ }
2090
+ process.exit(0);
2091
+ }
2092
+
1834
2093
  // Handle --setup-terminal (legacy terminal wizard)
1835
2094
  if (args.includes("--setup-terminal")) {
1836
2095
  const configDirArg = getArgValue("--config-dir");
@@ -1,8 +1,10 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { resolve, dirname, isAbsolute, relative, join } from "node:path";
3
3
  import { execSync, spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { homedir } from "node:os";
6
+ import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
7
+ import { getWorkflowContract } from "../workflow/workflow-contract.mjs";
6
8
 
7
9
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
10
  const CONFIG_FILES = [
@@ -10,6 +12,10 @@ const CONFIG_FILES = [
10
12
  ".bosun.json",
11
13
  "bosun.json",
12
14
  ];
15
+ const WORKFLOW_CONTRACT_NODE_TYPES = Object.freeze({
16
+ read: new Set(["read-workflow-contract", "action.read_workflow_contract"]),
17
+ validate: new Set(["workflow-contract-validation", "action.workflow_contract_validation"]),
18
+ });
13
19
 
14
20
  function parseBool(value) {
15
21
  return ["1", "true", "yes", "on"].includes(
@@ -92,6 +98,9 @@ function resolveConfigDir(repoRoot) {
92
98
  return packageDir;
93
99
  }
94
100
 
101
+ const sandbox = ensureTestRuntimeSandbox();
102
+ if (sandbox?.configDir) return sandbox.configDir;
103
+
95
104
  const preferWindowsDirs =
96
105
  process.platform === "win32" && !isWslInteropRuntime();
97
106
  const baseDir =
@@ -166,6 +175,36 @@ function findConfigFile(configDir) {
166
175
  return null;
167
176
  }
168
177
 
178
+ function workflowHasContractNodes(definition) {
179
+ const nodes = Array.isArray(definition?.nodes) ? definition.nodes : [];
180
+ let hasRead = false;
181
+ let hasValidate = false;
182
+ for (const node of nodes) {
183
+ const type = String(node?.type || "").trim();
184
+ if (WORKFLOW_CONTRACT_NODE_TYPES.read.has(type)) hasRead = true;
185
+ if (WORKFLOW_CONTRACT_NODE_TYPES.validate.has(type)) hasValidate = true;
186
+ }
187
+ return hasRead && hasValidate;
188
+ }
189
+
190
+ function hasEnabledWorkflowContractStep(repoRoot) {
191
+ const workflowDir = resolve(repoRoot, ".bosun", "workflows");
192
+ if (!existsSync(workflowDir)) return false;
193
+
194
+ for (const file of readdirSync(workflowDir)) {
195
+ if (!file.endsWith(".json")) continue;
196
+ try {
197
+ const definition = JSON.parse(readFileSync(resolve(workflowDir, file), "utf8"));
198
+ if (definition?.enabled === false) continue;
199
+ if (workflowHasContractNodes(definition)) return true;
200
+ } catch {
201
+ /* ignore malformed workflow files here; other checks can catch them */
202
+ }
203
+ }
204
+
205
+ return false;
206
+ }
207
+
169
208
  function validateExecutors(raw, issues) {
170
209
  if (!raw) return;
171
210
  const entries = String(raw)
@@ -671,6 +710,17 @@ export function runConfigDoctor(options = {}) {
671
710
  });
672
711
  }
673
712
 
713
+ const workflowContract = getWorkflowContract(repoRoot);
714
+ if (workflowContract.found && !hasEnabledWorkflowContractStep(repoRoot)) {
715
+ issues.warnings.push({
716
+ code: "WORKFLOW_CONTRACT_STEP_DISABLED",
717
+ message:
718
+ "WORKFLOW.md exists but no enabled workflow includes the read/validate workflow-contract steps.",
719
+ fix:
720
+ "Install or update the session-start workflow (for example template-task-lifecycle) so it includes `read-workflow-contract` and `workflow-contract-validation`.",
721
+ });
722
+ }
723
+
674
724
  issues.infos.push({
675
725
  code: "PATHS",
676
726
  message: `Config directory: ${configDir}`,
@@ -988,4 +1038,3 @@ export function formatWorkspaceHealthReport(result) {
988
1038
  return lines.join("\n");
989
1039
  }
990
1040
 
991
-