bosun 0.40.21 → 0.41.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 (41) hide show
  1. package/agent/agent-custom-tools.mjs +23 -5
  2. package/agent/agent-pool.mjs +6 -2
  3. package/agent/primary-agent.mjs +81 -7
  4. package/bench/swebench/bosun-swebench.mjs +5 -0
  5. package/cli.mjs +208 -3
  6. package/config/config-doctor.mjs +51 -2
  7. package/config/config.mjs +103 -3
  8. package/github/github-auth-manager.mjs +70 -19
  9. package/infra/library-manager.mjs +894 -60
  10. package/infra/monitor.mjs +8 -2
  11. package/infra/session-tracker.mjs +13 -3
  12. package/infra/test-runtime.mjs +267 -0
  13. package/package.json +8 -5
  14. package/server/setup-web-server.mjs +4 -1
  15. package/server/ui-server.mjs +1323 -20
  16. package/task/task-claims.mjs +6 -10
  17. package/ui/components/chat-view.js +18 -1
  18. package/ui/components/workspace-switcher.js +321 -9
  19. package/ui/demo-defaults.js +11746 -9470
  20. package/ui/demo.html +9 -1
  21. package/ui/modules/router.js +1 -1
  22. package/ui/modules/voice-client-sdk.js +1 -1
  23. package/ui/modules/voice-client.js +33 -2
  24. package/ui/styles/components.css +514 -1
  25. package/ui/tabs/library.js +410 -55
  26. package/ui/tabs/tasks.js +1052 -506
  27. package/ui/tabs/workflow-canvas-utils.mjs +30 -0
  28. package/ui/tabs/workflows.js +914 -298
  29. package/voice/voice-agents-sdk.mjs +1 -1
  30. package/voice/voice-relay.mjs +24 -16
  31. package/workflow/project-detection.mjs +559 -0
  32. package/workflow/workflow-contract.mjs +433 -232
  33. package/workflow/workflow-engine.mjs +181 -30
  34. package/workflow/workflow-nodes.mjs +304 -6
  35. package/workflow/workflow-templates.mjs +92 -16
  36. package/workflow-templates/agents.mjs +20 -19
  37. package/workflow-templates/code-quality.mjs +20 -14
  38. package/workflow-templates/task-batch.mjs +3 -2
  39. package/workflow-templates/task-execution.mjs +752 -0
  40. package/workflow-templates/task-lifecycle.mjs +34 -8
  41. package/workspace/workspace-manager.mjs +151 -0
@@ -561,12 +561,30 @@ export async function invokeCustomTool(rootDir, toolId, args = [], opts = {}) {
561
561
  timeout,
562
562
  maxBuffer: 10 * 1024 * 1024, // 10 MB
563
563
  });
564
- stdout = out.stdout;
565
- stderr = out.stderr;
564
+ // Node versions/environments may resolve promisified execFile as:
565
+ // - { stdout, stderr } (modern child_process custom promisify)
566
+ // - stdout string/buffer only (legacy/mocked fallback)
567
+ // - [stdout, stderr] tuple (some custom wrappers)
568
+ if (out && typeof out === "object" && !Array.isArray(out)) {
569
+ stdout = String(out.stdout ?? "");
570
+ stderr = String(out.stderr ?? "");
571
+ } else if (Array.isArray(out)) {
572
+ stdout = String(out[0] ?? "");
573
+ stderr = String(out[1] ?? "");
574
+ } else {
575
+ stdout = String(out ?? "");
576
+ stderr = "";
577
+ }
566
578
  } catch (err) {
567
- stdout = err.stdout || "";
568
- stderr = err.stderr || err.message || "";
569
- exitCode = err.code ?? 1;
579
+ stdout = String(err?.stdout ?? "");
580
+ stderr = String(err?.stderr ?? err?.message ?? "");
581
+ const numericExit = Number(err?.code);
582
+ const numericStatus = Number(err?.status);
583
+ exitCode = Number.isFinite(numericExit)
584
+ ? numericExit
585
+ : Number.isFinite(numericStatus)
586
+ ? numericStatus
587
+ : 1;
570
588
  }
571
589
 
572
590
  // Record usage non-blocking
@@ -53,6 +53,7 @@ import {
53
53
  streamRetryDelay,
54
54
  MAX_STREAM_RETRIES,
55
55
  } from "../infra/stream-resilience.mjs";
56
+ import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
56
57
  import { compressAllItems, estimateSavings, estimateContextUsagePct, recordShreddingEvent } from "../workspace/context-cache.mjs";
57
58
  import { resolveContextShreddingOptions } from "../config/context-shredding-config.mjs";
58
59
 
@@ -2521,7 +2522,10 @@ export async function execPooledPrompt(userMessage, options = {}) {
2521
2522
  /** @type {Map<string, ThreadRecord>} In-memory registry keyed by taskKey */
2522
2523
  const threadRegistry = new Map();
2523
2524
 
2524
- const THREAD_REGISTRY_FILE = resolve(__dirname, "..", "logs", "thread-registry.json");
2525
+ const testSandbox = ensureTestRuntimeSandbox();
2526
+ const THREAD_REGISTRY_FILE = testSandbox?.cacheDir
2527
+ ? resolve(testSandbox.cacheDir, "thread-registry.json")
2528
+ : resolve(__dirname, "..", "logs", "thread-registry.json");
2525
2529
  const THREAD_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12 hours
2526
2530
 
2527
2531
  /** Maximum turns before a thread is considered exhausted and must be replaced */
@@ -2707,7 +2711,7 @@ async function loadThreadRegistry() {
2707
2711
  async function saveThreadRegistry() {
2708
2712
  try {
2709
2713
  const { writeFile, mkdir } = await import("node:fs/promises");
2710
- await mkdir(resolve(__dirname, "..", "logs"), { recursive: true });
2714
+ await mkdir(dirname(THREAD_REGISTRY_FILE), { recursive: true });
2711
2715
  const obj = Object.fromEntries(threadRegistry);
2712
2716
  await writeFile(THREAD_REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
2713
2717
  } catch {
@@ -67,7 +67,9 @@ import {
67
67
  import { getModelsForExecutor, normalizeExecutorKey } from "../task/task-complexity.mjs";
68
68
 
69
69
  /** Valid agent interaction modes */
70
- const VALID_MODES = ["ask", "agent", "plan", "web", "instant"];
70
+ const CORE_MODES = ["ask", "agent", "plan", "web", "instant"];
71
+ /** Custom modes loaded from library */
72
+ const _customModes = new Map();
71
73
 
72
74
  const MODE_ALIASES = Object.freeze({
73
75
  code: "agent",
@@ -116,7 +118,37 @@ function normalizeAgentMode(rawMode, fallback = "agent") {
116
118
  const normalized = String(rawMode || "").trim().toLowerCase();
117
119
  if (!normalized) return fallback;
118
120
  const mapped = MODE_ALIASES[normalized] || normalized;
119
- return VALID_MODES.includes(mapped) ? mapped : fallback;
121
+ return getValidModes().includes(mapped) ? mapped : fallback;
122
+ }
123
+
124
+ /**
125
+ * Get all valid modes including dynamically registered custom modes.
126
+ * @returns {string[]}
127
+ */
128
+ function getValidModes() {
129
+ return [...CORE_MODES, ..._customModes.keys()];
130
+ }
131
+
132
+ /**
133
+ * Get mode prefix for a given mode, including custom modes.
134
+ * @param {string} mode
135
+ * @returns {string}
136
+ */
137
+ function getModePrefix(mode) {
138
+ if (MODE_PREFIXES[mode] !== undefined) return MODE_PREFIXES[mode];
139
+ const custom = _customModes.get(mode);
140
+ return custom?.prefix || "";
141
+ }
142
+
143
+ /**
144
+ * Get execution policy for a given mode, including custom modes.
145
+ * @param {string} mode
146
+ * @returns {object|null}
147
+ */
148
+ function getModeExecPolicy(mode) {
149
+ if (MODE_EXEC_POLICIES[mode]) return MODE_EXEC_POLICIES[mode];
150
+ const custom = _customModes.get(mode);
151
+ return custom?.execPolicy || null;
120
152
  }
121
153
 
122
154
  function normalizeAttachments(input) {
@@ -908,7 +940,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
908
940
  (options && options.sessionType ? String(options.sessionType) : "") ||
909
941
  "primary";
910
942
  const effectiveMode = normalizeAgentMode(options.mode || agentMode, agentMode);
911
- const modePolicy = MODE_EXEC_POLICIES[effectiveMode] || null;
943
+ const modePolicy = getModeExecPolicy(effectiveMode);
912
944
  const timeoutMs = options.timeoutMs || modePolicy?.timeoutMs || PRIMARY_EXEC_TIMEOUT_MS;
913
945
  const maxFailoverAttempts = Number.isInteger(options.maxFailoverAttempts)
914
946
  ? Math.max(0, Number(options.maxFailoverAttempts))
@@ -918,7 +950,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
918
950
  const attachmentsAppended = options.attachmentsAppended === true;
919
951
 
920
952
  // Apply mode prefix (options.mode overrides the global setting for this call)
921
- const modePrefix = MODE_PREFIXES[effectiveMode] || "";
953
+ const modePrefix = getModePrefix(effectiveMode);
922
954
  const messageWithAttachments = attachments.length && !attachmentsAppended
923
955
  ? appendAttachmentsToPrompt(userMessage, attachments).message
924
956
  : userMessage;
@@ -1241,8 +1273,8 @@ export function getAgentMode() {
1241
1273
  */
1242
1274
  export function setAgentMode(mode) {
1243
1275
  const normalized = normalizeAgentMode(mode, "");
1244
- if (!VALID_MODES.includes(normalized)) {
1245
- return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${VALID_MODES.join(", ")}` };
1276
+ if (!getValidModes().includes(normalized)) {
1277
+ return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${getValidModes().join(", ")}` };
1246
1278
  }
1247
1279
  agentMode = normalized;
1248
1280
  return { ok: true, mode: agentMode };
@@ -1254,10 +1286,52 @@ export function setAgentMode(mode) {
1254
1286
  * @returns {string}
1255
1287
  */
1256
1288
  export function applyModePrefix(userMessage) {
1257
- const prefix = MODE_PREFIXES[agentMode] || "";
1289
+ const prefix = getModePrefix(agentMode);
1258
1290
  return prefix ? prefix + userMessage : userMessage;
1259
1291
  }
1260
1292
 
1293
+ /**
1294
+ * Register a custom interaction mode at runtime.
1295
+ * Core modes cannot be overridden.
1296
+ * @param {string} id
1297
+ * @param {{ prefix?: string, execPolicy?: object|null, toolFilter?: object|null, description?: string }} config
1298
+ */
1299
+ export function registerCustomMode(id, config) {
1300
+ if (!id || typeof id !== "string") return;
1301
+ const modeId = id.trim().toLowerCase();
1302
+ if (CORE_MODES.includes(modeId)) return;
1303
+ _customModes.set(modeId, {
1304
+ prefix: config.prefix || "",
1305
+ execPolicy: config.execPolicy || null,
1306
+ toolFilter: config.toolFilter || null,
1307
+ description: config.description || "",
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * List all available modes (core + custom) with metadata.
1313
+ * @returns {Array<{id: string, description: string, core: boolean}>}
1314
+ */
1315
+ export function listAvailableModes() {
1316
+ const modes = CORE_MODES.map((m) => ({
1317
+ id: m,
1318
+ description: MODE_PREFIXES[m]?.slice(0, 80) || "Full agentic behavior",
1319
+ core: true,
1320
+ }));
1321
+ for (const [id, cfg] of _customModes) {
1322
+ modes.push({ id, description: cfg.description, core: false });
1323
+ }
1324
+ return modes;
1325
+ }
1326
+
1327
+ /**
1328
+ * Get all registered custom modes.
1329
+ * @returns {Array<{id: string, prefix: string, execPolicy: object|null, toolFilter: object|null, description: string}>}
1330
+ */
1331
+ export function getCustomModes() {
1332
+ return [..._customModes.entries()].map(([id, cfg]) => ({ id, ...cfg }));
1333
+ }
1334
+
1261
1335
  /**
1262
1336
  * Get the list of available agent adapters with capabilities.
1263
1337
  * @returns {Array<{id:string, name:string, provider:string, available:boolean, busy:boolean, capabilities:object}>}
@@ -217,6 +217,11 @@ export function sha256File(pathLike) {
217
217
 
218
218
  export function safeGit(args, cwd = process.cwd()) {
219
219
  try {
220
+ // Block dangerous git arguments that could execute arbitrary commands
221
+ const blocked = ["--upload-pack", "--exec", "-c"];
222
+ for (const a of args) {
223
+ if (blocked.some((b) => String(a).startsWith(b))) return "";
224
+ }
220
225
  return execFileSync("git", args, { encoding: "utf8", cwd }).trim();
221
226
  } catch {
222
227
  return "";
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,
@@ -140,6 +141,12 @@ function showHelp() {
140
141
  --workspace-switch <id> Switch active workspace
141
142
  --workspace-add-repo Add repo to workspace (interactive)
142
143
  --workspace-health Run workspace health diagnostics
144
+ --workspace-pause <id> Pause a workspace (no new workflows)
145
+ --workspace-resume <id> Resume a paused workspace
146
+ --workspace-disable <id> Disable a workspace entirely
147
+ --workspace-status Show state summary of all workspaces
148
+ --workspace-executors <id> Show/set executor config for workspace
149
+ [--max-concurrent N] [--pool shared|dedicated] [--weight N]
143
150
 
144
151
  TASK MANAGEMENT
145
152
  task list [--status s] [--json] List tasks with optional filters
@@ -252,6 +259,9 @@ function resolveConfigDirForCli() {
252
259
  const repoLocalConfigDir = resolveRepoLocalBosunDir(repoRoot);
253
260
  if (repoLocalConfigDir) return repoLocalConfigDir;
254
261
 
262
+ const sandbox = ensureTestRuntimeSandbox();
263
+ if (sandbox?.configDir) return sandbox.configDir;
264
+
255
265
  const preferWindowsDirs =
256
266
  process.platform === "win32" && !isWslInteropRuntime();
257
267
  const baseDir = preferWindowsDirs
@@ -368,10 +378,21 @@ function uniqueResolvedPaths(paths) {
368
378
  return results;
369
379
  }
370
380
 
381
+ function getWorkspaceScopedCacheDirCandidate(repoRootPath) {
382
+ const bosunDir = process.env.BOSUN_DIR || resolveConfigDirForCli();
383
+ if (!bosunDir || !repoRootPath) return null;
384
+ const parts = String(repoRootPath).replace(/\\/g, "/").split("/").filter(Boolean);
385
+ const repoName = parts.at(-1);
386
+ const workspaceName = parts.at(-2);
387
+ if (!repoName || !workspaceName) return null;
388
+ return resolve(bosunDir, "workspaces", workspaceName, repoName, ".cache");
389
+ }
390
+
371
391
  function getRuntimeCacheDirCandidates(extraCacheDirs = []) {
372
392
  return uniqueResolvedPaths([
373
393
  ...extraCacheDirs,
374
394
  runtimeCacheDir,
395
+ getWorkspaceScopedCacheDirCandidate(runtimeRepoRoot),
375
396
  process.env.BOSUN_DIR ? resolve(process.env.BOSUN_DIR, ".cache") : null,
376
397
  resolve(__dirname, ".cache"),
377
398
  resolve(process.cwd(), ".cache"),
@@ -625,6 +646,42 @@ function findGhostDaemonPids() {
625
646
  }
626
647
  }
627
648
 
649
+ function findGhostSentinelPids() {
650
+ if (process.platform === "win32") {
651
+ try {
652
+ const out = execFileSync(
653
+ "powershell",
654
+ [
655
+ "-NoProfile",
656
+ "-Command",
657
+ "Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(node|electron)(\\.exe)?$' -and $_.CommandLine -match 'telegram-sentinel\\.mjs' } | Select-Object -ExpandProperty ProcessId",
658
+ ],
659
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
660
+ ).trim();
661
+ if (!out) return [];
662
+ return out
663
+ .split(/\r?\n/)
664
+ .map((s) => parseInt(String(s).trim(), 10))
665
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
666
+ } catch {
667
+ return [];
668
+ }
669
+ }
670
+ try {
671
+ const out = execFileSync(
672
+ "pgrep",
673
+ ["-f", "telegram-sentinel\\.mjs"],
674
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 3000 },
675
+ ).trim();
676
+ return out
677
+ .split("\n")
678
+ .map((s) => parseInt(s.trim(), 10))
679
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
680
+ } catch {
681
+ return [];
682
+ }
683
+ }
684
+
628
685
  function writePidFile(pid) {
629
686
  try {
630
687
  mkdirSync(dirname(DAEMON_PID_FILE), { recursive: true });
@@ -852,10 +909,14 @@ function daemonStatus() {
852
909
  } else {
853
910
  // Check for ghost daemon-child processes (alive but no PID file)
854
911
  const ghosts = findGhostDaemonPids();
912
+ const ghostSentinels = findGhostSentinelPids();
855
913
  if (ghosts.length > 0) {
856
914
  console.log(` :alert: bosun daemon is NOT tracked (no PID file), but ${ghosts.length} ghost process(es) found: ${ghosts.join(", ")}`);
857
915
  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.`);
916
+ if (ghostSentinels.length > 0) {
917
+ console.log(` Ghost sentinel restart owner(s) detected: ${ghostSentinels.join(", ")}`);
918
+ }
919
+ console.log(` Run --terminate to stop restart owners, then --daemon to restart.`);
859
920
  } else {
860
921
  // Broader scan: portal, monitor, ui-server, etc. (non-daemon bosun processes)
861
922
  const allPids = findAllBosunProcessPids();
@@ -1108,6 +1169,7 @@ async function terminateBosun() {
1108
1169
  ]).map((pidFile) => readAlivePid(pidFile)),
1109
1170
  readSentinelPid(),
1110
1171
  ].filter((pid) => Number.isFinite(pid) && pid > 0);
1172
+ const sentinelGhostPids = findGhostSentinelPids();
1111
1173
  const manualStopHoldMs =
1112
1174
  Math.max(
1113
1175
  0,
@@ -1119,9 +1181,17 @@ async function terminateBosun() {
1119
1181
  ...daemonPids,
1120
1182
  ...monitorPids,
1121
1183
  ...sentinelPids,
1184
+ ...sentinelGhostPids,
1185
+ ...ghosts,
1122
1186
  ]);
1123
1187
  const restartOwnerPids = Array.from(
1124
- new Set([...ancestorPids, ...sentinelPids, ...daemonPids, ...ghosts]),
1188
+ new Set([
1189
+ ...ancestorPids,
1190
+ ...sentinelPids,
1191
+ ...sentinelGhostPids,
1192
+ ...daemonPids,
1193
+ ...ghosts,
1194
+ ]),
1125
1195
  ).filter((pid) => pid !== process.pid);
1126
1196
  const tracked = [...restartOwnerPids, ...monitorPids];
1127
1197
  const trackedPids = Array.from(new Set([...tracked, ...ghosts])).filter(
@@ -1742,7 +1812,11 @@ async function main() {
1742
1812
  console.log("\n Workspaces:");
1743
1813
  for (const ws of workspaces) {
1744
1814
  const marker = ws.id === active?.id ? " ← active" : "";
1745
- console.log(` ${ws.name} (${ws.id})${marker}`);
1815
+ const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
1816
+ const stateLabel = ws.state !== "active" ? ` [${ws.state}]` : "";
1817
+ console.log(` ${stateIcon} ${ws.name} (${ws.id})${stateLabel}${marker}`);
1818
+ const ex = ws.executors;
1819
+ console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
1746
1820
  for (const repo of ws.repos || []) {
1747
1821
  const primary = repo.primary ? " [primary]" : "";
1748
1822
  const exists = repo.exists ? "✓" : "✗";
@@ -1831,6 +1905,137 @@ async function main() {
1831
1905
  process.exit(result.ok ? 0 : 1);
1832
1906
  }
1833
1907
 
1908
+ // Handle --workspace-pause
1909
+ if (args.includes("--workspace-pause") || args.includes("workspace-pause")) {
1910
+ const { pauseWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1911
+ const configDirArg = getArgValue("--config-dir");
1912
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1913
+ const wsId = getArgValue("--workspace-pause") || getArgValue("workspace-pause");
1914
+ if (!wsId) {
1915
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-pause <id>");
1916
+ process.exit(1);
1917
+ }
1918
+ try {
1919
+ pauseWorkspace(configDir, wsId);
1920
+ const ws = getWorkspace(configDir, wsId);
1921
+ console.log(`\n ⏸ Workspace "${ws?.name || wsId}" paused — no new workflows will start\n`);
1922
+ } catch (err) {
1923
+ console.error(` Error: ${err.message}`);
1924
+ process.exit(1);
1925
+ }
1926
+ process.exit(0);
1927
+ }
1928
+
1929
+ // Handle --workspace-resume
1930
+ if (args.includes("--workspace-resume") || args.includes("workspace-resume")) {
1931
+ const { resumeWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1932
+ const configDirArg = getArgValue("--config-dir");
1933
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1934
+ const wsId = getArgValue("--workspace-resume") || getArgValue("workspace-resume");
1935
+ if (!wsId) {
1936
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-resume <id>");
1937
+ process.exit(1);
1938
+ }
1939
+ try {
1940
+ resumeWorkspace(configDir, wsId);
1941
+ const ws = getWorkspace(configDir, wsId);
1942
+ console.log(`\n ▶ Workspace "${ws?.name || wsId}" resumed — workflows will trigger normally\n`);
1943
+ } catch (err) {
1944
+ console.error(` Error: ${err.message}`);
1945
+ process.exit(1);
1946
+ }
1947
+ process.exit(0);
1948
+ }
1949
+
1950
+ // Handle --workspace-disable
1951
+ if (args.includes("--workspace-disable") || args.includes("workspace-disable")) {
1952
+ const { disableWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1953
+ const configDirArg = getArgValue("--config-dir");
1954
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1955
+ const wsId = getArgValue("--workspace-disable") || getArgValue("workspace-disable");
1956
+ if (!wsId) {
1957
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-disable <id>");
1958
+ process.exit(1);
1959
+ }
1960
+ try {
1961
+ disableWorkspace(configDir, wsId);
1962
+ const ws = getWorkspace(configDir, wsId);
1963
+ console.log(`\n ⏹ Workspace "${ws?.name || wsId}" disabled — no workflows, no executors\n`);
1964
+ } catch (err) {
1965
+ console.error(` Error: ${err.message}`);
1966
+ process.exit(1);
1967
+ }
1968
+ process.exit(0);
1969
+ }
1970
+
1971
+ // Handle --workspace-status
1972
+ if (args.includes("--workspace-status") || args.includes("workspace-status")) {
1973
+ const { getWorkspaceStateSummary } = await import("./workspace/workspace-manager.mjs");
1974
+ const configDirArg = getArgValue("--config-dir");
1975
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1976
+ const summary = getWorkspaceStateSummary(configDir);
1977
+ if (summary.length === 0) {
1978
+ console.log("\n No workspaces configured.\n");
1979
+ } else {
1980
+ console.log("\n Workspace Status:");
1981
+ for (const ws of summary) {
1982
+ const stateIcon = ws.state === "active" ? "●" : ws.state === "paused" ? "◐" : "○";
1983
+ const current = ws.isCurrent ? " ← current" : "";
1984
+ console.log(` ${stateIcon} ${ws.name} (${ws.id}) — ${ws.state}${current}`);
1985
+ const ex = ws.executors;
1986
+ console.log(` executors: max=${ex.maxConcurrent}, pool=${ex.pool}, weight=${ex.weight}`);
1987
+ if (ws.disabledWorkflows.length > 0) {
1988
+ console.log(` disabled workflows: ${ws.disabledWorkflows.join(", ")}`);
1989
+ }
1990
+ if (ws.enabledWorkflows.length > 0) {
1991
+ console.log(` enabled workflows: ${ws.enabledWorkflows.join(", ")}`);
1992
+ }
1993
+ }
1994
+ console.log("");
1995
+ }
1996
+ process.exit(0);
1997
+ }
1998
+
1999
+ // Handle --workspace-executors
2000
+ if (args.includes("--workspace-executors") || args.includes("workspace-executors")) {
2001
+ const { setWorkspaceExecutors, getWorkspace } = await import("./workspace/workspace-manager.mjs");
2002
+ const configDirArg = getArgValue("--config-dir");
2003
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
2004
+ const wsId = getArgValue("--workspace-executors") || getArgValue("workspace-executors");
2005
+ if (!wsId) {
2006
+ console.error(" Error: workspace ID required. Usage: bosun --workspace-executors <id> [--max-concurrent N] [--pool shared|dedicated] [--weight N]");
2007
+ process.exit(1);
2008
+ }
2009
+ const maxConcurrent = getArgValue("--max-concurrent");
2010
+ const pool = getArgValue("--pool");
2011
+ const weight = getArgValue("--weight");
2012
+ const hasUpdate = maxConcurrent || pool || weight;
2013
+ if (hasUpdate) {
2014
+ try {
2015
+ const opts = {};
2016
+ if (maxConcurrent) opts.maxConcurrent = Number(maxConcurrent);
2017
+ if (pool) opts.pool = pool;
2018
+ if (weight) opts.weight = Number(weight);
2019
+ const result = setWorkspaceExecutors(configDir, wsId, opts);
2020
+ console.log(`\n ✓ Executor config updated for "${wsId}":`, JSON.stringify(result), "\n");
2021
+ } catch (err) {
2022
+ console.error(` Error: ${err.message}`);
2023
+ process.exit(1);
2024
+ }
2025
+ } else {
2026
+ const ws = getWorkspace(configDir, wsId);
2027
+ if (!ws) {
2028
+ console.error(` Error: workspace "${wsId}" not found`);
2029
+ process.exit(1);
2030
+ }
2031
+ console.log(`\n Executor config for "${ws.name}":`);
2032
+ console.log(` maxConcurrent: ${ws.executors.maxConcurrent}`);
2033
+ console.log(` pool: ${ws.executors.pool}`);
2034
+ console.log(` weight: ${ws.executors.weight}\n`);
2035
+ }
2036
+ process.exit(0);
2037
+ }
2038
+
1834
2039
  // Handle --setup-terminal (legacy terminal wizard)
1835
2040
  if (args.includes("--setup-terminal")) {
1836
2041
  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
-