bosun 0.28.0 → 0.28.2

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/setup.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { createInterface } from "node:readline";
24
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
25
25
  import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
26
26
  import { execSync } from "node:child_process";
27
27
  import { execFileSync } from "node:child_process";
@@ -45,11 +45,14 @@ import {
45
45
  scaffoldAgentHookFiles,
46
46
  } from "./hook-profiles.mjs";
47
47
  import { detectLegacySetup, applyAllCompatibility } from "./compat.mjs";
48
+ import { DEFAULT_MODEL_PROFILES } from "./task-complexity.mjs";
49
+ import { pullWorkspaceRepos, listWorkspaces } from "./workspace-manager.mjs";
48
50
 
49
51
  const __dirname = dirname(fileURLToPath(import.meta.url));
50
52
 
51
53
  const isNonInteractive =
52
54
  process.argv.includes("--non-interactive") || process.argv.includes("-y");
55
+ const SETUP_TOTAL_STEPS = 10;
53
56
 
54
57
  // ── Zero-dependency terminal styling (replaces chalk) ────────────────────────
55
58
  const isTTY = process.stdout.isTTY;
@@ -114,6 +117,43 @@ function resolveConfigDir(repoRoot) {
114
117
  return resolve(baseDir, "bosun");
115
118
  }
116
119
 
120
+ function getSetupProgressPath(configDir) {
121
+ return resolve(configDir || __dirname, ".setup-progress.json");
122
+ }
123
+
124
+ function readSetupProgress(configDir) {
125
+ const progressPath = getSetupProgressPath(configDir);
126
+ if (!existsSync(progressPath)) return null;
127
+ try {
128
+ const raw = readFileSync(progressPath, "utf8");
129
+ const parsed = JSON.parse(raw);
130
+ return parsed && typeof parsed === "object" ? parsed : null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function writeSetupProgress(configDir, data) {
137
+ const progressPath = getSetupProgressPath(configDir);
138
+ try {
139
+ mkdirSync(dirname(progressPath), { recursive: true });
140
+ writeFileSync(progressPath, JSON.stringify(data, null, 2) + "\n", "utf8");
141
+ return progressPath;
142
+ } catch {
143
+ return "";
144
+ }
145
+ }
146
+
147
+ function clearSetupProgress(configDir) {
148
+ const progressPath = getSetupProgressPath(configDir);
149
+ if (!existsSync(progressPath)) return;
150
+ try {
151
+ rmSync(progressPath);
152
+ } catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+
117
157
  function printBanner() {
118
158
  const ver = getVersion();
119
159
  const title = `Codex Monitor — Setup Wizard v${ver}`;
@@ -135,6 +175,9 @@ function printBanner() {
135
175
  console.log(
136
176
  chalk.dim(" Press Enter to accept defaults shown in [brackets]."),
137
177
  );
178
+ console.log(
179
+ chalk.dim(" Setup writes .env and config files at the end (cancel anytime to discard changes)."),
180
+ );
138
181
  console.log("");
139
182
  }
140
183
 
@@ -143,6 +186,13 @@ function heading(text) {
143
186
  console.log(`\n ${chalk.bold(text)} ${chalk.dim(line)}\n`);
144
187
  }
145
188
 
189
+ function headingStep(step, label, markProgress) {
190
+ if (typeof markProgress === "function") {
191
+ markProgress(step, label);
192
+ }
193
+ heading(`Step ${step} of ${SETUP_TOTAL_STEPS} — ${label}`);
194
+ }
195
+
146
196
  function check(label, ok, hint) {
147
197
  const icon = ok ? "✅" : "❌";
148
198
  console.log(` ${icon} ${label}`);
@@ -162,6 +212,13 @@ function warn(msg) {
162
212
  console.log(` ⚠️ ${msg}`);
163
213
  }
164
214
 
215
+ function escapeTelegramHtml(value) {
216
+ return String(value || "")
217
+ .replace(/&/g, "&")
218
+ .replace(/</g, "&lt;")
219
+ .replace(/>/g, "&gt;");
220
+ }
221
+
165
222
  function commandExists(cmd) {
166
223
  try {
167
224
  execSync(`${process.platform === "win32" ? "where" : "which"} ${cmd}`, {
@@ -524,6 +581,65 @@ function bundledBinExists(cmd) {
524
581
  return existsSync(base) || existsSync(base + ".cmd");
525
582
  }
526
583
 
584
+ function normalizeRepoSlug(owner, repo) {
585
+ const cleanOwner = String(owner || "").trim();
586
+ const cleanRepo = String(repo || "")
587
+ .trim()
588
+ .replace(/\.git$/i, "");
589
+ if (!cleanOwner || !cleanRepo) return "";
590
+ return `${cleanOwner}/${cleanRepo}`;
591
+ }
592
+
593
+ function parseRepoSlugFromUrl(value) {
594
+ const trimmed = String(value || "").trim();
595
+ if (!trimmed) return "";
596
+
597
+ const directMatch = trimmed.match(
598
+ /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/,
599
+ );
600
+ if (directMatch) {
601
+ return normalizeRepoSlug(directMatch[1], directMatch[2]);
602
+ }
603
+
604
+ const scpMatch = trimmed.match(
605
+ /^[^@]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/,
606
+ );
607
+ if (scpMatch) {
608
+ return normalizeRepoSlug(scpMatch[1], scpMatch[2]);
609
+ }
610
+
611
+ const hostPathMatch = trimmed.match(
612
+ /^[^:\/]+:([^/]+)\/([^/]+?)(?:\.git)?$/,
613
+ );
614
+ if (hostPathMatch) {
615
+ return normalizeRepoSlug(hostPathMatch[1], hostPathMatch[2]);
616
+ }
617
+
618
+ let path = "";
619
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
620
+ try {
621
+ const parsed = new URL(trimmed);
622
+ path = parsed.pathname || "";
623
+ } catch {
624
+ const pathMatch = trimmed.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+\/(.+)$/i);
625
+ if (pathMatch) {
626
+ path = `/${pathMatch[1]}`;
627
+ }
628
+ }
629
+ }
630
+
631
+ if (path) {
632
+ const segments = path.split("/").filter(Boolean);
633
+ if (segments.length >= 2) {
634
+ const owner = segments[segments.length - 2];
635
+ const repo = segments[segments.length - 1];
636
+ return normalizeRepoSlug(owner, repo);
637
+ }
638
+ }
639
+
640
+ return "";
641
+ }
642
+
527
643
  function detectRepoSlug(cwd) {
528
644
  try {
529
645
  const remote = execSync("git remote get-url origin", {
@@ -531,8 +647,8 @@ function detectRepoSlug(cwd) {
531
647
  cwd: cwd || process.cwd(),
532
648
  stdio: ["pipe", "pipe", "ignore"],
533
649
  }).trim();
534
- const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)/);
535
- return match ? match[1] : null;
650
+ const parsed = parseRepoSlugFromUrl(remote);
651
+ return parsed || null;
536
652
  } catch {
537
653
  return null;
538
654
  }
@@ -563,6 +679,121 @@ function detectProjectName(repoRoot) {
563
679
  return basename(repoRoot);
564
680
  }
565
681
 
682
+ function formatModelVariant(profile) {
683
+ if (!profile?.model && !profile?.variant) return "";
684
+ if (profile?.model && profile?.variant) {
685
+ return `${profile.model} (${profile.variant})`;
686
+ }
687
+ return profile?.model || profile?.variant || "";
688
+ }
689
+
690
+ function printExecutorModelReference() {
691
+ const codexProfiles = DEFAULT_MODEL_PROFILES?.CODEX || {};
692
+ const copilotProfiles = DEFAULT_MODEL_PROFILES?.COPILOT || {};
693
+ const codexLow = formatModelVariant(codexProfiles.low);
694
+ const codexMed = formatModelVariant(codexProfiles.medium);
695
+ const codexHigh = formatModelVariant(codexProfiles.high);
696
+ const copilotLow = formatModelVariant(copilotProfiles.low);
697
+ const copilotMed = formatModelVariant(copilotProfiles.medium);
698
+ const copilotHigh = formatModelVariant(copilotProfiles.high);
699
+
700
+ console.log(chalk.dim(" Default model/variant reference (model → variant):"));
701
+ if (codexLow || codexMed || codexHigh) {
702
+ console.log(
703
+ chalk.dim(
704
+ ` CODEX: ${[codexLow, codexMed, codexHigh].filter(Boolean).join(" · ")}`,
705
+ ),
706
+ );
707
+ }
708
+ if (copilotLow || copilotMed || copilotHigh) {
709
+ console.log(
710
+ chalk.dim(
711
+ ` COPILOT (Claude): ${[copilotLow, copilotMed, copilotHigh].filter(Boolean).join(" · ")}`,
712
+ ),
713
+ );
714
+ }
715
+ console.log(
716
+ chalk.dim(
717
+ " Variants are the tokens used in EXECUTORS/config (ex: CODEX:DEFAULT, COPILOT:CLAUDE_OPUS_4_6).",
718
+ ),
719
+ );
720
+ console.log(
721
+ chalk.dim(
722
+ " Model names are used for overrides (ex: CODEX → gpt-5.2-codex, COPILOT → opus-4.6).",
723
+ ),
724
+ );
725
+ console.log(
726
+ chalk.dim(
727
+ " Copilot GPT variants (if used) follow tokens like GPT_4_1; Claude variants are CLAUDE_*.",
728
+ ),
729
+ );
730
+ console.log();
731
+ }
732
+
733
+ function buildRepositoryChoices(configJson, repoRoot) {
734
+ const choices = [];
735
+ const seen = new Set();
736
+
737
+ const pushChoice = (input) => {
738
+ if (!input) return;
739
+ const name = String(input.name || "").trim();
740
+ const slug = String(input.slug || "").trim();
741
+ const workspace = String(input.workspace || input.workspaceId || "").trim();
742
+ if (!name && !slug) return;
743
+ const key = slug || name;
744
+ if (seen.has(`${workspace}:${key}`)) return;
745
+ seen.add(`${workspace}:${key}`);
746
+ const labelParts = [];
747
+ if (workspace) labelParts.push(`ws:${workspace}`);
748
+ labelParts.push(name || slug);
749
+ if (slug && name && slug !== name) labelParts.push(`(${slug})`);
750
+ const label = labelParts.join(" ");
751
+ choices.push({
752
+ label,
753
+ name,
754
+ slug,
755
+ workspace,
756
+ value: key,
757
+ });
758
+ };
759
+
760
+ if (Array.isArray(configJson?.workspaces)) {
761
+ for (const ws of configJson.workspaces) {
762
+ const wsId = String(ws?.id || "").trim();
763
+ const wsName = String(ws?.name || wsId || "").trim();
764
+ const wsLabel = wsName || wsId;
765
+ for (const repo of ws?.repos || []) {
766
+ pushChoice({
767
+ name: repo?.name,
768
+ slug: repo?.slug,
769
+ workspace: wsLabel,
770
+ });
771
+ }
772
+ }
773
+ }
774
+
775
+ if (Array.isArray(configJson?.repositories)) {
776
+ for (const repo of configJson.repositories) {
777
+ pushChoice(repo);
778
+ }
779
+ }
780
+
781
+ if (choices.length === 0 && repoRoot) {
782
+ pushChoice({ name: basename(repoRoot) });
783
+ }
784
+
785
+ return choices;
786
+ }
787
+
788
+ function defaultVariantForExecutor(executor) {
789
+ const normalized = String(executor || "").trim().toUpperCase();
790
+ if (normalized === "CODEX") return "DEFAULT";
791
+ if (normalized === "COPILOT" || normalized === "CLAUDE") {
792
+ return "CLAUDE_OPUS_4_6";
793
+ }
794
+ return "DEFAULT";
795
+ }
796
+
566
797
  function runGhCommand(args, cwd) {
567
798
  const normalizedArgs = Array.isArray(args)
568
799
  ? args.map((entry) => String(entry))
@@ -591,6 +822,47 @@ function detectGitHubUserLogin(cwd) {
591
822
  }
592
823
  }
593
824
 
825
+ function getGitHubAuthStatus(cwd) {
826
+ if (!commandExists("gh")) {
827
+ return { ok: false, reason: "gh CLI not found" };
828
+ }
829
+ const login = detectGitHubUserLogin(cwd);
830
+ if (login) {
831
+ return { ok: true, login };
832
+ }
833
+ return { ok: false, reason: "gh auth required" };
834
+ }
835
+
836
+ function getGitHubAuthScopes(cwd) {
837
+ if (!commandExists("gh")) return [];
838
+ try {
839
+ const output = execSync("gh auth status --hostname github.com 2>&1", {
840
+ encoding: "utf8",
841
+ cwd: cwd || process.cwd(),
842
+ stdio: ["ignore", "pipe", "pipe"],
843
+ });
844
+ const line = String(output || "")
845
+ .split(/\r?\n/)
846
+ .find((entry) => entry.toLowerCase().includes("token scopes"));
847
+ if (!line) return [];
848
+ const scopesText = line.split(":").slice(1).join(":");
849
+ return scopesText
850
+ .split(",")
851
+ .map((scope) => scope.trim())
852
+ .filter(Boolean);
853
+ } catch {
854
+ return [];
855
+ }
856
+ }
857
+
858
+ function tryRunGhCommand(args, cwd) {
859
+ try {
860
+ return { ok: true, output: runGhCommand(args, cwd), error: "" };
861
+ } catch (err) {
862
+ return { ok: false, output: "", error: formatGhErrorReason(err) };
863
+ }
864
+ }
865
+
594
866
  function collectProjectCandidates(node, out) {
595
867
  if (node === null || node === undefined) return;
596
868
  if (Array.isArray(node)) {
@@ -819,6 +1091,85 @@ function resolveOrCreateGitHubProjectNumber(options) {
819
1091
  return resolveOrCreateGitHubProject(options).number;
820
1092
  }
821
1093
 
1094
+ const DEFAULT_GITHUB_PROJECT_STATUSES = {
1095
+ todo: "Todo",
1096
+ inprogress: "In Progress",
1097
+ inreview: "In Review",
1098
+ done: "Done",
1099
+ cancelled: "Cancelled",
1100
+ };
1101
+
1102
+ const PROJECT_STATUS_ALIASES = {
1103
+ todo: ["todo", "to do", "backlog", "queued"],
1104
+ inprogress: ["in progress", "in-progress", "doing", "active"],
1105
+ inreview: ["in review", "review", "needs review", "ready for review"],
1106
+ done: ["done", "complete", "completed", "closed"],
1107
+ cancelled: ["cancelled", "canceled", "abandoned", "wontfix", "won't fix"],
1108
+ };
1109
+
1110
+ function normalizeStatusOption(value) {
1111
+ return String(value || "")
1112
+ .toLowerCase()
1113
+ .replace(/[_-]/g, " ")
1114
+ .replace(/\s+/g, " ")
1115
+ .trim();
1116
+ }
1117
+
1118
+ function getGitHubProjectFields({ owner, number, cwd, runCommand = runGhCommand }) {
1119
+ if (!owner || !number) return [];
1120
+ try {
1121
+ const raw = runCommand(
1122
+ ["project", "field-list", String(number), "--owner", owner, "--format", "json"],
1123
+ cwd,
1124
+ );
1125
+ const parsed = JSON.parse(String(raw || "[]"));
1126
+ return Array.isArray(parsed) ? parsed : [];
1127
+ } catch {
1128
+ return [];
1129
+ }
1130
+ }
1131
+
1132
+ function resolveProjectStatusMapping(statusOptions = []) {
1133
+ const normalizedOptions = statusOptions
1134
+ .map((opt) => ({
1135
+ name: String(opt?.name || "").trim(),
1136
+ norm: normalizeStatusOption(opt?.name || ""),
1137
+ }))
1138
+ .filter((opt) => opt.name && opt.norm);
1139
+
1140
+ const findOption = (candidates) => {
1141
+ for (const candidate of candidates) {
1142
+ const normCandidate = normalizeStatusOption(candidate);
1143
+ const found = normalizedOptions.find((opt) => opt.norm === normCandidate);
1144
+ if (found) return found.name;
1145
+ }
1146
+ return "";
1147
+ };
1148
+
1149
+ const mapping = {};
1150
+ for (const [key, aliases] of Object.entries(PROJECT_STATUS_ALIASES)) {
1151
+ const desired = DEFAULT_GITHUB_PROJECT_STATUSES[key];
1152
+ const candidates = [desired, ...aliases];
1153
+ mapping[key] = findOption(candidates);
1154
+ }
1155
+
1156
+ const fallbacks = [];
1157
+ if (!mapping.inreview && mapping.inprogress) {
1158
+ mapping.inreview = mapping.inprogress;
1159
+ fallbacks.push({ key: "inreview", value: mapping.inprogress });
1160
+ }
1161
+ if (!mapping.cancelled && mapping.done) {
1162
+ mapping.cancelled = mapping.done;
1163
+ fallbacks.push({ key: "cancelled", value: mapping.done });
1164
+ }
1165
+
1166
+ const missing = Object.entries(mapping)
1167
+ .filter(([, value]) => !value)
1168
+ .map(([key]) => key);
1169
+
1170
+ return { mapping, missing, fallbacks };
1171
+ }
1172
+
822
1173
  function getDefaultPromptOverrides() {
823
1174
  const entries = getAgentPromptDefinitions().map((def) => [
824
1175
  def.key,
@@ -1695,8 +2046,35 @@ echo "Cleanup complete."
1695
2046
  async function main() {
1696
2047
  printBanner();
1697
2048
 
2049
+ const repoRoot = detectRepoRoot();
2050
+ const configDir = resolveConfigDir(repoRoot);
2051
+ const slug = detectRepoSlug();
2052
+ const projectName = detectProjectName(repoRoot);
2053
+ const setupProgress = readSetupProgress(configDir);
2054
+ const markSetupProgress = (step, label) => {
2055
+ writeSetupProgress(configDir, {
2056
+ status: "incomplete",
2057
+ step,
2058
+ total: SETUP_TOTAL_STEPS,
2059
+ label,
2060
+ updatedAt: new Date().toISOString(),
2061
+ });
2062
+ };
2063
+
2064
+ if (setupProgress?.status === "incomplete") {
2065
+ const label = setupProgress.label ? ` — ${setupProgress.label}` : "";
2066
+ const timestamp = setupProgress.updatedAt
2067
+ ? ` (last updated ${setupProgress.updatedAt})`
2068
+ : "";
2069
+ info(
2070
+ `Detected a previous setup that ended at step ${setupProgress.step}${label}${timestamp}.`,
2071
+ );
2072
+ info("Setup writes .env/config files at the end, so partial runs do not persist changes.");
2073
+ console.log();
2074
+ }
2075
+
1698
2076
  // ── Step 1: Prerequisites ───────────────────────────────
1699
- heading("Step 1 of 9 — Prerequisites");
2077
+ headingStep(1, "Prerequisites", markSetupProgress);
1700
2078
  const hasNode = check(
1701
2079
  "Node.js ≥ 18",
1702
2080
  Number(process.versions.node.split(".")[0]) >= 18,
@@ -1744,10 +2122,6 @@ async function main() {
1744
2122
  process.exit(1);
1745
2123
  }
1746
2124
 
1747
- const repoRoot = detectRepoRoot();
1748
- const configDir = resolveConfigDir(repoRoot);
1749
- const slug = detectRepoSlug();
1750
- const projectName = detectProjectName(repoRoot);
1751
2125
  const envCandidates = [resolve(configDir, ".env"), resolve(repoRoot, ".env")];
1752
2126
  const seenEnvPaths = new Set();
1753
2127
  let detectedEnv = false;
@@ -1794,10 +2168,12 @@ async function main() {
1794
2168
  }
1795
2169
 
1796
2170
  const prompt = createPrompt();
2171
+ let aborted = false;
2172
+ let cloneWorkspacesAfterSetup = false;
1797
2173
 
1798
2174
  try {
1799
2175
  // ── Step 2: Setup Mode + Project Identity ─────────────
1800
- heading("Step 2 of 9 — Setup Mode & Project Identity");
2176
+ headingStep(2, "Setup Mode & Project Identity", markSetupProgress);
1801
2177
  const setupProfileIdx = await prompt.choose(
1802
2178
  "How much setup detail do you want?",
1803
2179
  SETUP_PROFILES.map((profile) => profile.label),
@@ -1819,7 +2195,7 @@ async function main() {
1819
2195
  configJson.projectName = env.PROJECT_NAME;
1820
2196
 
1821
2197
  // ── Step 3: Workspace & Repository ─────────────────────
1822
- heading("Step 3 of 9 — Workspace & Repository Configuration");
2198
+ headingStep(3, "Workspace & Repository Configuration", markSetupProgress);
1823
2199
 
1824
2200
  const useWorkspaces = await prompt.confirm(
1825
2201
  "Set up multi-repo workspaces? (organizes repos into ~/bosun/workspaces/)",
@@ -1850,16 +2226,21 @@ async function main() {
1850
2226
  ` Repo ${repoIdx + 1} — git URL (SSH or HTTPS)`,
1851
2227
  repoIdx === 0 ? (env.GITHUB_REPO ? `git@github.com:${env.GITHUB_REPO}.git` : "") : "",
1852
2228
  );
1853
- const defaultName = repoUrl
2229
+ const parsedSlug = parseRepoSlugFromUrl(repoUrl);
2230
+ const parsedRepoName = parsedSlug ? parsedSlug.split("/")[1] : "";
2231
+ const defaultNameFromUrl = repoUrl
1854
2232
  ? (repoUrl.match(/[/:]([^/]+?)(?:\.git)?$/) || [])[1] || ""
1855
2233
  : "";
2234
+ const defaultName = defaultNameFromUrl || parsedRepoName;
2235
+ const repoSlugDefault =
2236
+ parsedSlug || (repoIdx === 0 ? env.GITHUB_REPO : "");
1856
2237
  const repoName = await prompt.ask(
1857
2238
  ` Repo ${repoIdx + 1} — directory name`,
1858
2239
  defaultName || (repoIdx === 0 ? basename(repoRoot) : ""),
1859
2240
  );
1860
2241
  const repoSlug = await prompt.ask(
1861
2242
  ` Repo ${repoIdx + 1} — GitHub slug (org/repo)`,
1862
- repoIdx === 0 ? env.GITHUB_REPO : "",
2243
+ repoSlugDefault,
1863
2244
  );
1864
2245
 
1865
2246
  wsRepos.push({
@@ -1896,6 +2277,11 @@ async function main() {
1896
2277
  if (configJson.workspaces.length > 0) {
1897
2278
  configJson.activeWorkspace = configJson.workspaces[0].id;
1898
2279
  }
2280
+
2281
+ cloneWorkspacesAfterSetup = await prompt.confirm(
2282
+ "Clone/pull workspace repos now (recommended)?",
2283
+ true,
2284
+ );
1899
2285
  } else {
1900
2286
  // Single-repo mode (classic) — still works as before
1901
2287
  const multiRepo = isAdvancedSetup
@@ -1918,9 +2304,11 @@ async function main() {
1918
2304
  ` Repo ${repoIdx + 1} — local path`,
1919
2305
  repoIdx === 0 ? repoRoot : "",
1920
2306
  );
2307
+ const repoSlugDefault =
2308
+ detectRepoSlug(repoPath) || (repoIdx === 0 ? env.GITHUB_REPO : "");
1921
2309
  const repoSlug = await prompt.ask(
1922
2310
  ` Repo ${repoIdx + 1} — GitHub slug`,
1923
- repoIdx === 0 ? env.GITHUB_REPO : "",
2311
+ repoSlugDefault,
1924
2312
  );
1925
2313
  configJson.repositories.push({
1926
2314
  name: repoName,
@@ -1946,7 +2334,7 @@ async function main() {
1946
2334
  }
1947
2335
 
1948
2336
  // ── Step 4: Executor Configuration ─────────────────────
1949
- heading("Step 4 of 9 — Executor / Agent Configuration");
2337
+ headingStep(4, "Executor / Agent Configuration", markSetupProgress);
1950
2338
  console.log(" Executors are the AI agents that work on tasks.\n");
1951
2339
 
1952
2340
  const presetOptions = isAdvancedSetup
@@ -1977,6 +2365,13 @@ async function main() {
1977
2365
 
1978
2366
  if (presetKey === "custom") {
1979
2367
  info("Define your executors. Enter empty name to finish.\n");
2368
+ printExecutorModelReference();
2369
+ console.log(
2370
+ chalk.dim(
2371
+ " Weights are relative (they do not need to sum to 100). Percentages are normalized from the total.",
2372
+ ),
2373
+ );
2374
+ console.log();
1980
2375
  let execIdx = 0;
1981
2376
  const roles = ["primary", "backup", "tertiary"];
1982
2377
  while (true) {
@@ -1986,8 +2381,13 @@ async function main() {
1986
2381
  );
1987
2382
  if (!eName) break;
1988
2383
  const eType = await prompt.ask(" Executor type", "COPILOT");
1989
- const eVariant = await prompt.ask(" Model variant", "CLAUDE_OPUS_4_6");
1990
- const eWeight = Number(await prompt.ask(" Weight (1-100)", "50"));
2384
+ const eVariant = await prompt.ask(
2385
+ " Model variant",
2386
+ defaultVariantForExecutor(eType),
2387
+ );
2388
+ const eWeight = Number(
2389
+ await prompt.ask(" Weight (relative number)", "50"),
2390
+ );
1991
2391
  configJson.executors.push({
1992
2392
  name: eName,
1993
2393
  executor: eType.toUpperCase(),
@@ -1996,6 +2396,18 @@ async function main() {
1996
2396
  role: roles[execIdx] || `executor-${execIdx + 1}`,
1997
2397
  enabled: true,
1998
2398
  });
2399
+ const totalWeight = configJson.executors.reduce(
2400
+ (sum, entry) => sum + (Number(entry.weight) || 0),
2401
+ 0,
2402
+ );
2403
+ if (totalWeight > 0) {
2404
+ console.log(
2405
+ chalk.dim(
2406
+ ` Current total weight: ${totalWeight} (percentages will be normalized)`,
2407
+ ),
2408
+ );
2409
+ console.log();
2410
+ }
1999
2411
  execIdx++;
2000
2412
  }
2001
2413
  } else {
@@ -2060,7 +2472,7 @@ async function main() {
2060
2472
  }
2061
2473
 
2062
2474
  // ── Step 5: AI Provider ────────────────────────────────
2063
- heading("Step 5 of 9 — AI / Codex Provider");
2475
+ headingStep(5, "AI / Codex Provider", markSetupProgress);
2064
2476
  console.log(
2065
2477
  " Codex Monitor uses the Codex SDK for crash analysis & autofix.\n",
2066
2478
  );
@@ -2146,7 +2558,7 @@ async function main() {
2146
2558
  }
2147
2559
 
2148
2560
  // ── Step 6: Telegram ──────────────────────────────────
2149
- heading("Step 6 of 9 — Telegram Notifications");
2561
+ headingStep(6, "Telegram Notifications", markSetupProgress);
2150
2562
  console.log(
2151
2563
  " The Telegram bot sends real-time notifications and lets you\n" +
2152
2564
  " control the orchestrator via /status, /tasks, /restart, etc.\n",
@@ -2330,10 +2742,13 @@ async function main() {
2330
2742
  if (testNow) {
2331
2743
  info("Sending test message...");
2332
2744
  try {
2745
+ const projectLabel = escapeTelegramHtml(
2746
+ env.PROJECT_NAME || configJson.projectName || "Unknown",
2747
+ );
2333
2748
  const testMsg =
2334
- "🤖 *Telegram Bot Test*\n\n" +
2749
+ "🤖 <b>Telegram Bot Test</b>\n\n" +
2335
2750
  "Your bosun Telegram bot is configured correctly!\n\n" +
2336
- `Project: ${env.PROJECT_NAME || configJson.projectName || "Unknown"}\n` +
2751
+ `Project: ${projectLabel}\n` +
2337
2752
  "Try: /status, /tasks, /help";
2338
2753
 
2339
2754
  const response = await fetch(
@@ -2344,7 +2759,7 @@ async function main() {
2344
2759
  body: JSON.stringify({
2345
2760
  chat_id: env.TELEGRAM_CHAT_ID,
2346
2761
  text: testMsg,
2347
- parse_mode: "Markdown",
2762
+ parse_mode: "HTML",
2348
2763
  }),
2349
2764
  },
2350
2765
  );
@@ -2371,36 +2786,119 @@ async function main() {
2371
2786
  }
2372
2787
 
2373
2788
  // ── Step 7: Kanban + Execution ─────────────────────────
2374
- heading("Step 7 of 9 — Kanban & Execution");
2789
+ headingStep(7, "Kanban & Execution", markSetupProgress);
2790
+ const repoChoices = buildRepositoryChoices(configJson, repoRoot);
2791
+ let selectedRepoChoice = repoChoices[0] || null;
2792
+ if (repoChoices.length > 1) {
2793
+ console.log(
2794
+ chalk.dim(
2795
+ " Multiple repositories detected. Select which repo this bosun instance should manage tasks for.",
2796
+ ),
2797
+ );
2798
+ const repoLabels = repoChoices.map((choice) => choice.label);
2799
+ repoLabels.push("Decide later (skip)");
2800
+ const defaultRepoIdx = (() => {
2801
+ const slugDefault =
2802
+ process.env.GITHUB_REPOSITORY || env.GITHUB_REPO || "";
2803
+ if (slugDefault) {
2804
+ const matchIdx = repoChoices.findIndex(
2805
+ (choice) => choice.slug === slugDefault,
2806
+ );
2807
+ if (matchIdx >= 0) return matchIdx;
2808
+ }
2809
+ const primaryIdx = repoChoices.findIndex(
2810
+ (choice) => choice.slug && choice.slug === configJson?.repositories?.find((repo) => repo.primary)?.slug,
2811
+ );
2812
+ return primaryIdx >= 0 ? primaryIdx : 0;
2813
+ })();
2814
+ const selectedIdx = await prompt.choose(
2815
+ "Primary repo for task board",
2816
+ repoLabels,
2817
+ Math.min(defaultRepoIdx, repoChoices.length - 1),
2818
+ );
2819
+ if (selectedIdx >= 0 && selectedIdx < repoChoices.length) {
2820
+ selectedRepoChoice = repoChoices[selectedIdx];
2821
+ if (selectedRepoChoice?.value) {
2822
+ configJson.defaultRepository = selectedRepoChoice.value;
2823
+ }
2824
+ } else {
2825
+ selectedRepoChoice = null;
2826
+ }
2827
+ console.log();
2828
+ info(
2829
+ "If you need different task backends per repo, run separate bosun instances with different configs.",
2830
+ );
2831
+ console.log();
2832
+ } else if (selectedRepoChoice?.value) {
2833
+ configJson.defaultRepository = selectedRepoChoice.value;
2834
+ }
2835
+
2375
2836
  const backendDefault = String(
2376
2837
  process.env.KANBAN_BACKEND || configJson.kanban?.backend || "internal",
2377
2838
  )
2378
2839
  .trim()
2379
2840
  .toLowerCase();
2380
- const backendIdx = await prompt.choose(
2381
- "Select task board backend:",
2382
- [
2383
- "Internal Store (internal, recommended primary)",
2384
- "Vibe-Kanban (vk)",
2385
- "GitHub Issues (github)",
2386
- "Jira Issues (jira)",
2387
- ],
2388
- backendDefault === "vk"
2389
- ? 1
2390
- : backendDefault === "github"
2391
- ? 2
2392
- : backendDefault === "jira"
2393
- ? 3
2394
- : 0,
2395
- );
2396
- const selectedKanbanBackend =
2397
- backendIdx === 1
2398
- ? "vk"
2399
- : backendIdx === 2
2400
- ? "github"
2401
- : backendIdx === 3
2402
- ? "jira"
2403
- : "internal";
2841
+ let selectedKanbanBackend = "internal";
2842
+ let skipGitHubProjectSetup = false;
2843
+ while (true) {
2844
+ const backendIdx = await prompt.choose(
2845
+ "Select task board backend:",
2846
+ [
2847
+ "Internal Store (internal, recommended primary)",
2848
+ "Vibe-Kanban (vk)",
2849
+ "GitHub Issues (github)",
2850
+ "Jira Issues (jira)",
2851
+ ],
2852
+ backendDefault === "vk"
2853
+ ? 1
2854
+ : backendDefault === "github"
2855
+ ? 2
2856
+ : backendDefault === "jira"
2857
+ ? 3
2858
+ : 0,
2859
+ );
2860
+ selectedKanbanBackend =
2861
+ backendIdx === 1
2862
+ ? "vk"
2863
+ : backendIdx === 2
2864
+ ? "github"
2865
+ : backendIdx === 3
2866
+ ? "jira"
2867
+ : "internal";
2868
+
2869
+ if (selectedKanbanBackend !== "github") break;
2870
+
2871
+ const ghStatus = getGitHubAuthStatus(repoRoot);
2872
+ if (ghStatus.ok) break;
2873
+
2874
+ warn(
2875
+ "GitHub auth is required to auto-detect projects, create boards, and sync issues.",
2876
+ );
2877
+ info(
2878
+ "If you do not plan to use GitHub as the task manager, pick Internal, Jira, or Vibe-Kanban.",
2879
+ );
2880
+ const ghActionIdx = await prompt.choose(
2881
+ "How do you want to proceed?",
2882
+ [
2883
+ "Continue with GitHub (skip Projects setup for now)",
2884
+ "Choose a different backend",
2885
+ "Exit setup",
2886
+ ],
2887
+ 1,
2888
+ );
2889
+ if (ghActionIdx === 0) {
2890
+ skipGitHubProjectSetup = true;
2891
+ break;
2892
+ }
2893
+ if (ghActionIdx === 1) {
2894
+ continue;
2895
+ }
2896
+ aborted = true;
2897
+ break;
2898
+ }
2899
+ if (aborted) {
2900
+ return;
2901
+ }
2404
2902
  env.KANBAN_BACKEND = selectedKanbanBackend;
2405
2903
  const syncPolicyIdx = await prompt.choose(
2406
2904
  "Select sync policy:",
@@ -2527,20 +3025,103 @@ async function main() {
2527
3025
  selectedExecutorMode === "hybrid";
2528
3026
 
2529
3027
  if (selectedKanbanBackend === "github") {
2530
- const [slugOwner, slugRepo] = String(slug || "").split("/", 2);
2531
- env.GITHUB_REPO_OWNER = await prompt.ask(
2532
- "GitHub owner/org",
2533
- process.env.GITHUB_REPO_OWNER || slugOwner || "",
2534
- );
2535
- env.GITHUB_REPO_NAME = await prompt.ask(
2536
- "GitHub repository name",
2537
- process.env.GITHUB_REPO_NAME || slugRepo || basename(repoRoot),
3028
+ const primaryRepoSlug =
3029
+ selectedRepoChoice?.slug ||
3030
+ configJson.repositories?.find((repo) => repo.primary && repo.slug)?.slug ||
3031
+ "";
3032
+ const repoSlugDefaults = [
3033
+ process.env.GITHUB_REPOSITORY,
3034
+ process.env.GITHUB_REPO,
3035
+ env.GITHUB_REPO,
3036
+ primaryRepoSlug,
3037
+ slug,
3038
+ ]
3039
+ .map((entry) => String(entry || "").trim())
3040
+ .filter(Boolean);
3041
+ const repoSlugDefault = repoSlugDefaults[0] || "";
3042
+
3043
+ info(
3044
+ "Pick the repo that should receive tasks (issues/projects). If you have multiple orgs, use the owner for that repo.",
2538
3045
  );
3046
+ let repoInput = repoSlugDefault;
3047
+ if (repoChoices.length > 1) {
3048
+ const repoLabels = repoChoices.map((choice) => choice.label);
3049
+ repoLabels.push("Enter manually");
3050
+ const repoIdx = await prompt.choose(
3051
+ "Select GitHub repo for tasks",
3052
+ repoLabels,
3053
+ repoChoices.findIndex(
3054
+ (choice) => choice.slug && choice.slug === repoSlugDefault,
3055
+ ) >= 0
3056
+ ? repoChoices.findIndex(
3057
+ (choice) => choice.slug && choice.slug === repoSlugDefault,
3058
+ )
3059
+ : 0,
3060
+ );
3061
+ if (repoIdx >= 0 && repoIdx < repoChoices.length) {
3062
+ repoInput = repoChoices[repoIdx]?.slug || repoChoices[repoIdx]?.name || repoSlugDefault;
3063
+ } else {
3064
+ repoInput = await prompt.ask(
3065
+ "GitHub repository for tasks (owner/repo or URL)",
3066
+ repoSlugDefault,
3067
+ );
3068
+ }
3069
+ } else {
3070
+ repoInput = await prompt.ask(
3071
+ "GitHub repository for tasks (owner/repo or URL)",
3072
+ repoSlugDefault,
3073
+ );
3074
+ }
3075
+ const parsedRepoSlug = parseRepoSlugFromUrl(repoInput || repoSlugDefault);
3076
+ if (parsedRepoSlug) {
3077
+ const [repoOwner, repoName] = parsedRepoSlug.split("/", 2);
3078
+ env.GITHUB_REPO_OWNER = repoOwner || "";
3079
+ env.GITHUB_REPO_NAME = repoName || "";
3080
+ } else {
3081
+ const [slugOwner, slugRepo] = String(slug || "").split("/", 2);
3082
+ env.GITHUB_REPO_OWNER = await prompt.ask(
3083
+ "GitHub owner/org",
3084
+ process.env.GITHUB_REPO_OWNER || slugOwner || "",
3085
+ );
3086
+ env.GITHUB_REPO_NAME = await prompt.ask(
3087
+ "GitHub repository name",
3088
+ process.env.GITHUB_REPO_NAME || slugRepo || basename(repoRoot),
3089
+ );
3090
+ }
2539
3091
  if (env.GITHUB_REPO_OWNER && env.GITHUB_REPO_NAME) {
2540
3092
  env.GITHUB_REPOSITORY = `${env.GITHUB_REPO_OWNER}/${env.GITHUB_REPO_NAME}`;
3093
+ env.GITHUB_REPO = env.GITHUB_REPOSITORY;
2541
3094
  env.KANBAN_PROJECT_ID = env.GITHUB_REPOSITORY;
2542
3095
  }
2543
3096
 
3097
+ if (env.GITHUB_REPOSITORY && !skipGitHubProjectSetup) {
3098
+ const repoCheck = tryRunGhCommand(
3099
+ ["repo", "view", env.GITHUB_REPOSITORY, "--json", "name", "--jq", ".name"],
3100
+ repoRoot,
3101
+ );
3102
+ if (!repoCheck.ok) {
3103
+ warn(
3104
+ `Could not access repo via gh: ${env.GITHUB_REPOSITORY}. ${repoCheck.error || ""}`.trim(),
3105
+ );
3106
+ const repoActionIdx = await prompt.choose(
3107
+ "Continue with GitHub backend?",
3108
+ [
3109
+ "Continue (use GitHub anyway)",
3110
+ "Switch to a different backend",
3111
+ "Exit setup",
3112
+ ],
3113
+ 0,
3114
+ );
3115
+ if (repoActionIdx === 1) {
3116
+ selectedKanbanBackend = "internal";
3117
+ env.KANBAN_BACKEND = selectedKanbanBackend;
3118
+ } else if (repoActionIdx === 2) {
3119
+ aborted = true;
3120
+ return;
3121
+ }
3122
+ }
3123
+ }
3124
+
2544
3125
  const githubTaskModeDefault = String(
2545
3126
  process.env.GITHUB_PROJECT_MODE ||
2546
3127
  configJson.kanban?.github?.mode ||
@@ -2556,7 +3137,48 @@ async function main() {
2556
3137
  ],
2557
3138
  githubTaskModeDefault === "issues" ? 1 : 0,
2558
3139
  );
2559
- const githubTaskMode = githubTaskModeIdx === 1 ? "issues" : "kanban";
3140
+ let githubTaskMode = githubTaskModeIdx === 1 ? "issues" : "kanban";
3141
+ if (skipGitHubProjectSetup && githubTaskMode === "kanban") {
3142
+ const downgrade = await prompt.confirm(
3143
+ "GitHub auth is missing. Switch to Issues-only mode for now?",
3144
+ true,
3145
+ );
3146
+ if (downgrade) githubTaskMode = "issues";
3147
+ }
3148
+
3149
+ if (githubTaskMode === "kanban" && !skipGitHubProjectSetup) {
3150
+ const scopes = getGitHubAuthScopes(repoRoot);
3151
+ const missingScopes = [];
3152
+ if (!scopes.includes("project")) missingScopes.push("project");
3153
+ if (env.GITHUB_REPO_OWNER && env.GITHUB_REPO_OWNER !== detectedLogin) {
3154
+ if (!scopes.includes("read:org")) missingScopes.push("read:org");
3155
+ }
3156
+ if (missingScopes.length > 0) {
3157
+ warn(
3158
+ `GitHub token is missing scopes required for Projects: ${missingScopes.join(
3159
+ ", ",
3160
+ )}.`,
3161
+ );
3162
+ info(
3163
+ "Run: gh auth refresh -h github.com -s project,repo,read:org",
3164
+ );
3165
+ const scopeActionIdx = await prompt.choose(
3166
+ "Proceed without Projects?",
3167
+ [
3168
+ "Use Issues-only for now",
3169
+ "Continue and try Projects anyway",
3170
+ "Exit setup",
3171
+ ],
3172
+ 0,
3173
+ );
3174
+ if (scopeActionIdx === 0) {
3175
+ githubTaskMode = "issues";
3176
+ } else if (scopeActionIdx === 2) {
3177
+ aborted = true;
3178
+ return;
3179
+ }
3180
+ }
3181
+ }
2560
3182
  env.GITHUB_PROJECT_MODE = githubTaskMode;
2561
3183
 
2562
3184
  const detectedLogin = detectGitHubUserLogin(repoRoot);
@@ -2585,7 +3207,7 @@ async function main() {
2585
3207
  env.BOSUN_TASK_LABELS = existingScopeLabels.join(",");
2586
3208
  env.BOSUN_ENFORCE_TASK_LABEL = "true";
2587
3209
 
2588
- if (githubTaskMode === "kanban") {
3210
+ if (githubTaskMode === "kanban" && !skipGitHubProjectSetup) {
2589
3211
  env.GITHUB_PROJECT_OWNER =
2590
3212
  process.env.GITHUB_PROJECT_OWNER || env.GITHUB_REPO_OWNER || "";
2591
3213
  env.GITHUB_PROJECT_TITLE = await prompt.ask(
@@ -2610,6 +3232,57 @@ async function main() {
2610
3232
  success(
2611
3233
  `GitHub Project linked: ${env.GITHUB_PROJECT_OWNER}#${resolvedProject.number} (${env.GITHUB_PROJECT_TITLE})`,
2612
3234
  );
3235
+
3236
+ const fields = getGitHubProjectFields({
3237
+ owner: env.GITHUB_PROJECT_OWNER,
3238
+ number: resolvedProject.number,
3239
+ cwd: repoRoot,
3240
+ });
3241
+ const statusField = fields.find(
3242
+ (field) =>
3243
+ String(field?.name || "").trim().toLowerCase() === "status",
3244
+ );
3245
+ if (statusField && Array.isArray(statusField.options)) {
3246
+ const { mapping, missing, fallbacks } = resolveProjectStatusMapping(
3247
+ statusField.options,
3248
+ );
3249
+ const statusEnvKeys = {
3250
+ todo: "GITHUB_PROJECT_STATUS_TODO",
3251
+ inprogress: "GITHUB_PROJECT_STATUS_INPROGRESS",
3252
+ inreview: "GITHUB_PROJECT_STATUS_INREVIEW",
3253
+ done: "GITHUB_PROJECT_STATUS_DONE",
3254
+ cancelled: "GITHUB_PROJECT_STATUS_CANCELLED",
3255
+ };
3256
+ for (const [key, value] of Object.entries(mapping)) {
3257
+ const envKey = statusEnvKeys[key];
3258
+ if (!envKey || !value) continue;
3259
+ if (!env[envKey] && !process.env[envKey]) {
3260
+ env[envKey] = value;
3261
+ }
3262
+ }
3263
+
3264
+ if (fallbacks.length > 0) {
3265
+ const fallbackSummary = fallbacks
3266
+ .map((entry) => `${entry.key} → ${entry.value}`)
3267
+ .join(", ");
3268
+ warn(
3269
+ `GitHub Project Status options missing. Using fallbacks: ${fallbackSummary}.`,
3270
+ );
3271
+ }
3272
+ if (missing.length > 0) {
3273
+ warn(
3274
+ `GitHub Project Status options still missing: ${missing.join(
3275
+ ", ",
3276
+ )}. Add them in GitHub or set GITHUB_PROJECT_STATUS_* overrides.`,
3277
+ );
3278
+ } else {
3279
+ info("GitHub Project Status mapping verified.");
3280
+ }
3281
+ } else {
3282
+ warn(
3283
+ "GitHub Project has no Status field metadata. Status sync may be limited.",
3284
+ );
3285
+ }
2613
3286
  } else {
2614
3287
  const reasonSuffix = resolvedProject.reason
2615
3288
  ? ` Reason: ${resolvedProject.reason}`
@@ -2618,6 +3291,10 @@ async function main() {
2618
3291
  `Could not auto-detect/create GitHub Project. Issues will still be created and can be linked later.${reasonSuffix}`,
2619
3292
  );
2620
3293
  }
3294
+ } else if (githubTaskMode === "kanban" && skipGitHubProjectSetup) {
3295
+ warn(
3296
+ "Skipping GitHub Project auto-setup (auth missing). You can re-run setup after `gh auth login`.",
3297
+ );
2621
3298
  }
2622
3299
 
2623
3300
  configJson.kanban = {
@@ -3478,7 +4155,7 @@ async function main() {
3478
4155
  }
3479
4156
 
3480
4157
  // ── Step 8: Optional Channels ─────────────────────────
3481
- heading("Step 8 of 10 — Optional Channels (WhatsApp & Container)");
4158
+ headingStep(8, "Optional Channels (WhatsApp & Container)", markSetupProgress);
3482
4159
 
3483
4160
  console.log(
3484
4161
  chalk.dim(
@@ -3541,7 +4218,7 @@ async function main() {
3541
4218
  }
3542
4219
 
3543
4220
  // ── Step 9: Desktop Shortcut ──────────────────────────
3544
- heading("Step 9 of 10 — Desktop Shortcut");
4221
+ headingStep(9, "Desktop Shortcut", markSetupProgress);
3545
4222
 
3546
4223
  const {
3547
4224
  getDesktopShortcutStatus,
@@ -3572,7 +4249,7 @@ async function main() {
3572
4249
  }
3573
4250
 
3574
4251
  // ── Step 10: Startup Service ───────────────────────────
3575
- heading("Step 10 of 10 — Startup Service");
4252
+ headingStep(10, "Startup Service", markSetupProgress);
3576
4253
 
3577
4254
  const { getStartupStatus, getStartupMethodName } =
3578
4255
  await import("./startup-service.mjs");
@@ -3610,6 +4287,29 @@ async function main() {
3610
4287
  // ── Write Files ─────────────────────────────────────────
3611
4288
  normalizeSetupConfiguration({ env, configJson, repoRoot, slug, configDir });
3612
4289
  await writeConfigFiles({ env, configJson, repoRoot, configDir });
4290
+ clearSetupProgress(configDir);
4291
+
4292
+ if (cloneWorkspacesAfterSetup && Array.isArray(configJson.workspaces) && configJson.workspaces.length > 0) {
4293
+ heading("Cloning Workspace Repos");
4294
+ for (const ws of configJson.workspaces) {
4295
+ const wsId = ws?.id;
4296
+ if (!wsId) continue;
4297
+ try {
4298
+ const results = pullWorkspaceRepos(configDir, wsId);
4299
+ for (const result of results) {
4300
+ if (result.success) {
4301
+ success(`Workspace ${wsId}: ${result.name} ready`);
4302
+ } else {
4303
+ warn(
4304
+ `Workspace ${wsId}: ${result.name} ${result.error ? `— ${result.error}` : "failed"}`,
4305
+ );
4306
+ }
4307
+ }
4308
+ } catch (err) {
4309
+ warn(`Workspace ${wsId}: clone/pull failed — ${err.message || err}`);
4310
+ }
4311
+ }
4312
+ }
3613
4313
  }
3614
4314
 
3615
4315
  // ── Non-Interactive Mode ─────────────────────────────────────────────────────