bosun 0.28.3 → 0.28.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -303,6 +303,74 @@ TELEGRAM_MINIAPP_ENABLED=false
303
303
  # Optional free-form constraints/scoping notes
304
304
  # PROJECT_REQUIREMENTS_NOTES=
305
305
 
306
+ # ─── Branch Strategy ──────────────────────────────────────────────────
307
+ # How agents work on branches for each task:
308
+ #
309
+ # worktree (default) — Each task gets an isolated ve/<id>-<slug> sub-branch
310
+ # that PRs back into the module/base branch.
311
+ # Best for: independent, high-parallelism work.
312
+ #
313
+ # direct — Agents push directly onto the module branch
314
+ # (no ve/ sub-branch). All work is sequential on that
315
+ # branch. Conflicts are resolved in real time.
316
+ # Best for: fast iteration on a well-scoped module.
317
+ #
318
+ # TASK_BRANCH_MODE=worktree
319
+ #
320
+ # Auto-detect origin/<module> as base_branch from conventional commit task titles
321
+ # e.g. "[m] feat(veid): add verification" → base_branch = origin/veid
322
+ # TASK_BRANCH_AUTO_MODULE=true
323
+ #
324
+ # Prefix used when building module branch refs (default: origin/)
325
+ # MODULE_BRANCH_PREFIX=origin/
326
+ #
327
+ # Default upstream target branch (main branch) for upstream sync and PRs
328
+ # DEFAULT_TARGET_BRANCH=main
329
+ #
330
+ # Merge origin/main into module branches before each push (keeps branches in
331
+ # sync with upstream continuously). Conflicts abort the merge and are resolved
332
+ # by the next agent working on that branch. Default: true
333
+ # TASK_UPSTREAM_SYNC_MAIN=true
334
+
335
+ # ─── GitHub App (Bosun[botswain] Identity + Auth) ────────────────────────────
336
+ # App: https://github.com/apps/bosun-botswain (slug: bosun-botswain)
337
+ # Bot identity: bosun-botswain[bot] (appears as contributor on every agent commit)
338
+ #
339
+ # Numeric App ID (shown on the App settings page under "About"):
340
+ # BOSUN_GITHUB_APP_ID=2911413
341
+ #
342
+ # OAuth Client ID (from App settings → Client ID):
343
+ # BOSUN_GITHUB_CLIENT_ID=Iv23liZpVhGePGka9gcL
344
+ #
345
+ # OAuth Client Secret (only needed for callback-based OAuth, not for Device Flow):
346
+ # BOSUN_GITHUB_CLIENT_SECRET=
347
+ #
348
+ # Webhook secret (set this in App settings → Webhook, and keep in sync):
349
+ # BOSUN_GITHUB_WEBHOOK_SECRET=
350
+ #
351
+ # Path to the PEM private key downloaded from App settings → Generate a private key:
352
+ # BOSUN_GITHUB_PRIVATE_KEY_PATH=/path/to/bosun-botswain.pem
353
+ #
354
+ # ─── Authentication Method ───────────────────────────────────────────────
355
+ # RECOMMENDED: Device Flow (like VS Code / Roo Code — no public URL needed!)
356
+ # 1. Set BOSUN_GITHUB_CLIENT_ID above
357
+ # 2. Enable "Device Flow" in GitHub App settings (Settings → Optional features)
358
+ # 3. Go to Settings → GitHub in the Bosun UI and click "Sign in with GitHub"
359
+ # 4. That's it — no callback URL, no tunnel URL, no client secret needed
360
+ #
361
+ # ALTERNATIVE: OAuth Callback (requires a stable public URL)
362
+ # Set BOSUN_GITHUB_CLIENT_ID + BOSUN_GITHUB_CLIENT_SECRET
363
+ # Register callback URL in App settings:
364
+ # https://<your-bosun-public-url>/api/github/callback
365
+ #
366
+ # WEBHOOKS (optional — for real-time PR/issue sync):
367
+ # Register webhook URL in App settings:
368
+ # https://<your-bosun-public-url>/api/webhooks/github/app
369
+ # Webhooks require a stable public URL. Without them, Bosun polls instead.
370
+ #
371
+ # Leave BOSUN_GITHUB_APP_ID unset to disable co-author trailer injection.
372
+ # BOSUN_GITHUB_APP_ID=
373
+
306
374
  # ─── Kanban Backend ──────────────────────────────────────────────────────────
307
375
  # Task-board backend:
308
376
  # internal - local task-store source of truth (recommended primary)
package/agent-prompts.mjs CHANGED
@@ -173,11 +173,12 @@ Return exactly one fenced json block with this shape:
173
173
  {
174
174
  "tasks": [
175
175
  {
176
- "title": "[m] Example task title",
176
+ "title": "[m] feat(veid): example task title",
177
177
  "description": "Problem statement and scope",
178
178
  "implementation_steps": ["step 1", "step 2"],
179
179
  "acceptance_criteria": ["criterion 1", "criterion 2"],
180
- "verification": ["test/check 1", "test/check 2"]
180
+ "verification": ["test/check 1", "test/check 2"],
181
+ "base_branch": "origin/veid"
181
182
  }
182
183
  ]
183
184
  }
@@ -187,6 +188,11 @@ Rules:
187
188
  - Provide at least the requested task count unless blocked by duplicate safeguards.
188
189
  - Keep titles unique and specific.
189
190
  - Keep file overlap low across tasks to maximize parallel execution.
191
+ - **Module branch routing:** When the task title follows conventional commit format
192
+ \`feat(module):\` or \`fix(module):\`, set \`base_branch\` to \`origin/<module>\`.
193
+ This routes the task to the module's dedicated branch for parallel, isolated development.
194
+ Examples: \`feat(veid):\` → \`"base_branch": "origin/veid"\`, \`fix(market):\` → \`"base_branch": "origin/market"\`.
195
+ Omit \`base_branch\` for cross-cutting tasks that span multiple modules.
190
196
  `,
191
197
  monitorMonitor: `# Bosun-Monitor Agent
192
198
 
@@ -240,14 +246,14 @@ You are running as a **Bosun-managed task agent**. Environment variables
240
246
  **After committing:**
241
247
  - If a precommit hook auto-applies additional formatting changes, add those
242
248
  to a follow-up commit before pushing.
243
- - Merge any upstream changes from the base branch before pushing:
244
- \`git fetch origin && git merge origin/<base-branch> --no-edit\`
245
- Resolve any conflicts that arise.
249
+ - Merge any upstream changes — BOTH from the base (module) branch AND from main:
250
+ \`git fetch origin && git merge origin/<base-branch> --no-edit && git merge origin/main --no-edit\`
251
+ Resolve any conflicts that arise before pushing.
246
252
  - Push: \`git push --set-upstream origin {{BRANCH}}\`
247
253
  - After a successful push, open a Pull Request:
248
254
  \`gh pr create --title "{{TASK_TITLE}}" --body "Closes task {{TASK_ID}}"\`
249
255
  - **Do NOT** run \`gh pr merge\` — the orchestrator handles merges after CI.
250
-
256
+ {{COAUTHOR_INSTRUCTION}}
251
257
  **Do NOT:**
252
258
  - Bypass pre-push hooks (\`git push --no-verify\` is forbidden).
253
259
  - Use \`git add .\` — stage files individually.
@@ -12,8 +12,8 @@
12
12
  * - Cost anomaly detection (unusually expensive sessions)
13
13
  */
14
14
 
15
- import { readFile, writeFile, appendFile, stat, watch } from "fs/promises";
16
- import { createReadStream, existsSync } from "fs";
15
+ import { readFile, writeFile, appendFile, stat, watch, mkdir } from "fs/promises";
16
+ import { createReadStream, existsSync, mkdirSync } from "fs";
17
17
  import { createInterface } from "readline";
18
18
  import { resolve, dirname } from "path";
19
19
  import { fileURLToPath } from "url";
@@ -73,30 +73,54 @@ export async function startAnalyzer() {
73
73
 
74
74
  console.log("[agent-work-analyzer] Starting...");
75
75
 
76
- // Ensure alerts log exists
77
- if (!existsSync(ALERTS_LOG)) {
78
- await writeFile(ALERTS_LOG, "");
76
+ // Ensure parent directory and alerts log exist
77
+ try {
78
+ const alertsDir = dirname(ALERTS_LOG);
79
+ if (!existsSync(alertsDir)) {
80
+ await mkdir(alertsDir, { recursive: true });
81
+ }
82
+ if (!existsSync(ALERTS_LOG)) {
83
+ await writeFile(ALERTS_LOG, "");
84
+ }
85
+ } catch (err) {
86
+ console.warn(`[agent-work-analyzer] Failed to init alerts log: ${err.message}`);
79
87
  }
80
88
 
81
89
  // Initial read of existing log
82
90
  if (existsSync(AGENT_WORK_STREAM)) {
83
91
  filePosition = await processLogFile(filePosition);
92
+ } else {
93
+ // Ensure the stream file exists so the watcher doesn't throw
94
+ try {
95
+ await writeFile(AGENT_WORK_STREAM, "");
96
+ } catch {
97
+ // May fail if another process creates it first — that's fine
98
+ }
84
99
  }
85
100
 
86
- // Watch for changes
101
+ // Watch for changes — retry loop handles the case where the file
102
+ // is deleted and recreated (e.g. log rotation).
87
103
  console.log(`[agent-work-analyzer] Watching: ${AGENT_WORK_STREAM}`);
88
104
 
89
- const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
90
-
91
- try {
92
- for await (const event of watcher) {
93
- if (event.eventType === "change") {
94
- filePosition = await processLogFile(filePosition);
105
+ while (isRunning) {
106
+ try {
107
+ const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
108
+ for await (const event of watcher) {
109
+ if (!isRunning) break;
110
+ if (event.eventType === "change") {
111
+ filePosition = await processLogFile(filePosition);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ if (!isRunning) break;
116
+ if (err.code === "ENOENT") {
117
+ // File was deleted — wait a bit and retry
118
+ await new Promise((r) => setTimeout(r, 5000));
119
+ continue;
95
120
  }
96
- }
97
- } catch (err) {
98
- if (err.code !== "ENOENT") {
99
121
  console.error(`[agent-work-analyzer] Watcher error: ${err.message}`);
122
+ // Wait before retrying to avoid busy-loop
123
+ await new Promise((r) => setTimeout(r, 10000));
100
124
  }
101
125
  }
102
126
  }
package/cli.mjs CHANGED
@@ -979,7 +979,10 @@ async function main() {
979
979
  const logDir = monitorPath ? resolve(dirname(monitorPath), "logs") : resolve(__dirname, "logs");
980
980
  const daemonLog = existsSync(DAEMON_LOG) ? DAEMON_LOG : resolve(logDir, "daemon.log");
981
981
  const monitorLog = resolve(logDir, "monitor.log");
982
- const logFile = existsSync(daemonLog) ? daemonLog : monitorLog;
982
+ // Prefer monitor.log that's where the real activity goes.
983
+ // daemon.log only has the startup line; monitor.mjs intercepts
984
+ // all console output and writes to monitor.log.
985
+ const logFile = existsSync(monitorLog) ? monitorLog : daemonLog;
983
986
 
984
987
  if (existsSync(logFile)) {
985
988
  console.log(
package/codex-config.mjs CHANGED
@@ -373,11 +373,18 @@ function normalizeWritableRoots(input, { repoRoot } = {}) {
373
373
  if (repo) {
374
374
  addRoot(repo);
375
375
  addRoot(resolve(repo, ".git"));
376
+ // Worktree checkout paths (used by task-executor)
377
+ addRoot(resolve(repo, ".cache", "worktrees"));
378
+ // Cache directories for agent work logs, build artifacts, etc.
379
+ addRoot(resolve(repo, ".cache"));
376
380
  const parent = dirname(repo);
377
381
  if (parent && parent !== repo) addRoot(parent);
378
382
  }
379
383
  }
380
384
 
385
+ // /tmp is needed for sandbox temp files, pip installs, etc.
386
+ addRoot("/tmp");
387
+
381
388
  return Array.from(roots);
382
389
  }
383
390
 
package/monitor.mjs CHANGED
@@ -312,7 +312,10 @@ function startAgentWorkAnalyzer() {
312
312
  if (agentWorkAnalyzerActive) return;
313
313
  if (process.env.AGENT_WORK_ANALYZER_ENABLED === "false") return;
314
314
  try {
315
- void startAnalyzer();
315
+ startAnalyzer().catch((err) => {
316
+ console.warn(`[monitor] agent-work analyzer async error: ${err.message}`);
317
+ agentWorkAnalyzerActive = false;
318
+ });
316
319
  agentWorkAnalyzerActive = true;
317
320
  console.log("[monitor] agent-work analyzer started");
318
321
  } catch (err) {
@@ -1531,8 +1534,9 @@ async function handleMonitorFailure(reason, err) {
1531
1534
 
1532
1535
  if (telegramToken && telegramChatId) {
1533
1536
  try {
1537
+ const shortMsg = message.length > 200 ? message.slice(0, 200) + "…" : message;
1534
1538
  await sendTelegramMessage(
1535
- `⚠️ bosun exception (${reason}). Attempting recovery (count=${failureCount}).`,
1539
+ `⚠️ bosun exception (${reason}): ${shortMsg}\n\nAttempting recovery (count=${failureCount}).`,
1536
1540
  );
1537
1541
  } catch {
1538
1542
  /* suppress Telegram errors during failure handling */
@@ -7551,6 +7555,27 @@ async function buildPlannerRuntimeContext(reason, details, numTasks) {
7551
7555
  };
7552
7556
  }
7553
7557
 
7558
+ /**
7559
+ * Extract a conventional-commit scope from a task title and return the
7560
+ * corresponding module branch ref (e.g. "origin/veid").
7561
+ * Respects TASK_BRANCH_AUTO_MODULE env var (default: true) and
7562
+ * MODULE_BRANCH_PREFIX (default: "origin/").
7563
+ */
7564
+ function extractModuleBaseBranchFromTitle(title) {
7565
+ const enabled = (process.env.TASK_BRANCH_AUTO_MODULE ?? "true") !== "false";
7566
+ if (!enabled || !title) return null;
7567
+ const match = String(title).match(
7568
+ /(?:^\[[^\]]+\]\s*)?(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\(([^)]+)\)/i,
7569
+ );
7570
+ if (!match) return null;
7571
+ const scope = match[1].toLowerCase().trim();
7572
+ // Exclude generic scopes that don't map to real module branches
7573
+ const generic = new Set(["deps", "app", "sdk", "cli", "api"]);
7574
+ if (generic.has(scope)) return null;
7575
+ const prefix = (process.env.MODULE_BRANCH_PREFIX || "origin/").replace(/\/*$/, "/");
7576
+ return `${prefix}${scope}`;
7577
+ }
7578
+
7554
7579
  function buildPlannerTaskDescription({
7555
7580
  plannerPrompt,
7556
7581
  reason,
@@ -7620,8 +7645,13 @@ function buildPlannerTaskDescription({
7620
7645
  "4. Prioritize reliability and unblockers first when errors/review backlog is elevated.",
7621
7646
  "5. Avoid duplicates with existing todo/inprogress/review tasks and open PRs.",
7622
7647
  "6. Prefer task sets that can run in parallel with minimal file overlap.",
7623
- "7. If a task should target a non-default epic/base branch, include `base_branch` in the JSON task object.",
7624
- ].join("\n");
7648
+ "7. **Module branch routing (important):** When a task's title follows conventional commit format",
7649
+ " `feat(module):` or `fix(module):`, ALWAYS set `base_branch` to `origin/<module>` in the JSON.",
7650
+ " This enables parallel branch-per-module execution where all work for a module accumulates on",
7651
+ " its dedicated branch and integrates upstream changes continuously.",
7652
+ " Examples: `feat(veid):` → `origin/veid`, `fix(market):` → `origin/market`.",
7653
+ " Do NOT set base_branch for cross-cutting tasks that modify many modules.",
7654
+ "8. If a task should target a non-default epic/base branch for other reasons, include `base_branch` in the JSON task object.",].join("\n");
7625
7655
  }
7626
7656
 
7627
7657
  function normalizePlannerTitleForComparison(title) {
@@ -7798,7 +7828,12 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
7798
7828
  title: task.title,
7799
7829
  description: task.description,
7800
7830
  status: "todo",
7801
- ...(task.baseBranch ? { baseBranch: task.baseBranch } : {}),
7831
+ // Honour explicit baseBranch from planner JSON, then fall back to
7832
+ // module auto-detection via conventional commit scope in the title.
7833
+ ...(() => {
7834
+ const b = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7835
+ return b ? { baseBranch: b } : {};
7836
+ })(),
7802
7837
  });
7803
7838
  if (createdTask?.id) {
7804
7839
  created.push({ id: createdTask.id, title: task.title });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.3",
3
+ "version": "0.28.4",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
package/primary-agent.mjs CHANGED
@@ -362,9 +362,13 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
362
362
  );
363
363
 
364
364
  if (result) {
365
+ // Extract human-readable text from structured responses
366
+ const text = typeof result === "string"
367
+ ? result
368
+ : result.finalResponse || result.text || result.message || JSON.stringify(result);
365
369
  tracker.recordEvent(sessionId, {
366
370
  role: "assistant",
367
- content: typeof result === "string" ? result : JSON.stringify(result),
371
+ content: text,
368
372
  timestamp: new Date().toISOString(),
369
373
  _sessionType: sessionType,
370
374
  });
package/setup.mjs CHANGED
@@ -1627,7 +1627,13 @@ function buildDefaultWritableRoots(repoRoot) {
1627
1627
  if (parent && parent !== repo) roots.add(parent);
1628
1628
  roots.add(repo);
1629
1629
  roots.add(resolve(repo, ".git"));
1630
+ // Worktree checkout paths (used by task-executor)
1631
+ roots.add(resolve(repo, ".cache", "worktrees"));
1632
+ // Cache directories for agent work logs, build artifacts, etc.
1633
+ roots.add(resolve(repo, ".cache"));
1630
1634
  }
1635
+ // /tmp needed for sandbox temp files, pip installs, etc.
1636
+ roots.add("/tmp");
1631
1637
  return Array.from(roots).join(",");
1632
1638
  }
1633
1639
 
package/task-executor.mjs CHANGED
@@ -113,6 +113,35 @@ const CODEX_TASK_LABELS = (() => {
113
113
 
114
114
  /** Watchdog interval: how often to check for stalled agent slots */
115
115
  const WATCHDOG_INTERVAL_MS = 60_000; // 1 minute
116
+
117
+ /**
118
+ * Returns the Co-authored-by trailer for bosun-botswain[bot], or empty string
119
+ * if the GitHub App ID is not configured. Used to attribute agent commits to
120
+ * the Bosun GitHub App so the bot shows up as a contributor.
121
+ *
122
+ * To enable: set BOSUN_GITHUB_APP_ID=<your-app-id> in .env
123
+ * App noreply email format: <id>+bosun-botswain[bot]@users.noreply.github.com
124
+ */
125
+ function getBosunCoAuthorLine() {
126
+ const appId = String(process.env.BOSUN_GITHUB_APP_ID || "").trim();
127
+ if (!appId) return "";
128
+ return `Co-authored-by: bosun-botswain[bot] <${appId}+bosun-botswain[bot]@users.noreply.github.com>`;
129
+ }
130
+
131
+ /**
132
+ * Returns a prompt instruction block telling the agent to append the Bosun
133
+ * co-author trailer to every commit. Empty string when app ID not configured.
134
+ */
135
+ function getBosunCoAuthorInstruction() {
136
+ const line = getBosunCoAuthorLine();
137
+ if (!line) return "";
138
+ return `\n**Attribution (required — do not omit):**
139
+ Every commit message MUST end with a blank line then this exact trailer:
140
+ \`\`\`
141
+ ${line}
142
+ \`\`\`
143
+ `;
144
+ }
116
145
  /** Grace period after task timeout before watchdog force-kills the slot */
117
146
  const WATCHDOG_GRACE_MS = 10 * 60_000; // 10 minutes — generous buffer, stream analysis handles real issues
118
147
  /** Max age for in-progress tasks to auto-resume after monitor restart */
@@ -212,11 +241,30 @@ function normalizeBranchName(value) {
212
241
  function extractScopeFromTitle(title) {
213
242
  if (!title) return null;
214
243
  const match = String(title).match(
215
- /(?:^\[P\d+\]\s*)?(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\(([^)]+)\)/i,
244
+ /(?:^\[[^\]]+\]\s*)?(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\(([^)]+)\)/i,
216
245
  );
217
246
  return match ? match[1].toLowerCase().trim() : null;
218
247
  }
219
248
 
249
+ /**
250
+ * If TASK_BRANCH_AUTO_MODULE=true (default), extract the conventional-commit
251
+ * scope from a task title and return the corresponding module branch ref.
252
+ *
253
+ * e.g. "[m] feat(veid): add verification" → "origin/veid"
254
+ * "[s] fix(market): resolve bid race" → "origin/market"
255
+ */
256
+ function resolveModuleAutoBaseBranch(title) {
257
+ const enabled = (process.env.TASK_BRANCH_AUTO_MODULE ?? "true") !== "false";
258
+ if (!enabled) return null;
259
+ const scope = extractScopeFromTitle(title);
260
+ if (!scope) return null;
261
+ // Exclude generic scopes that don't map to real module branches
262
+ const generic = new Set(["deps", "app", "sdk", "cli", "api"]);
263
+ if (generic.has(scope)) return null;
264
+ const prefix = (process.env.MODULE_BRANCH_PREFIX || "origin/").replace(/\/*$/, "/");
265
+ return `${prefix}${scope}`;
266
+ }
267
+
220
268
  function collectTaskLabels(task) {
221
269
  const labels = [];
222
270
  if (!task) return labels;
@@ -350,6 +398,10 @@ function resolveTaskBaseBranch(task, branchRouting, defaultTargetBranch) {
350
398
  const fromConfig = resolveUpstreamFromConfig(task, branchRouting);
351
399
  if (fromConfig) return normalizeBranchName(fromConfig);
352
400
 
401
+ // Auto-detect module branch from conventional commit scope in title
402
+ const moduleAuto = resolveModuleAutoBaseBranch(task.title || task.name);
403
+ if (moduleAuto) return normalizeBranchName(moduleAuto);
404
+
353
405
  return normalizeBranchName(defaultTargetBranch);
354
406
  }
355
407
 
@@ -3285,12 +3337,25 @@ class TaskExecutor {
3285
3337
  const requestedBranch = String(
3286
3338
  options?.branch || options?.resumeBranch || "",
3287
3339
  ).trim();
3340
+ // Resolve base branch first so direct-mode can reuse it for the working branch
3341
+ const baseBranch = this._resolveTaskBaseBranch(task);
3342
+ // Branch name strategy:
3343
+ // TASK_BRANCH_MODE=direct → work directly on the module branch (no ve/ sub-branch),
3344
+ // enabling sequential in-place work with real-time conflict
3345
+ // resolution by the same team of agents.
3346
+ // TASK_BRANCH_MODE=worktree (default) → create an isolated ve/<id>-<slug> sub-branch
3347
+ // that PRs back into the module/base branch.
3348
+ const branchMode = (process.env.TASK_BRANCH_MODE || "worktree").trim().toLowerCase();
3349
+ const directModeBranch =
3350
+ branchMode === "direct" && baseBranch
3351
+ ? normalizeBranchName(baseBranch)?.replace(/^origin\//, "") || null
3352
+ : null;
3288
3353
  const branch =
3289
3354
  requestedBranch ||
3290
3355
  task.branchName ||
3291
3356
  task.meta?.branch_name ||
3357
+ directModeBranch ||
3292
3358
  `ve/${taskId.substring(0, 8)}-${slugify(taskTitle)}`;
3293
- const baseBranch = this._resolveTaskBaseBranch(task);
3294
3359
  const taskRepoContext = this._resolveTaskRepoContext(task);
3295
3360
  const executionRepoRoot = taskRepoContext.repoRoot || this.repoRoot;
3296
3361
  const executionRepoSlug = taskRepoContext.repoSlug || this.repoSlug;
@@ -4124,6 +4189,7 @@ class TaskExecutor {
4124
4189
  REPO_ROOT: promptRepoRoot,
4125
4190
  TASK_WORKSPACE: promptWorkspace,
4126
4191
  TASK_REPOSITORY: promptRepository,
4192
+ COAUTHOR_INSTRUCTION: getBosunCoAuthorInstruction(),
4127
4193
  },
4128
4194
  fallbackPrompt,
4129
4195
  );
@@ -4196,6 +4262,11 @@ class TaskExecutor {
4196
4262
  `3. Re-run tests to verify`,
4197
4263
  `4. Commit and push your fixes`,
4198
4264
  ``,
4265
+ ...(getBosunCoAuthorLine() ? [
4266
+ `Attribution: append this trailer after each commit message body (blank line before it):`,
4267
+ getBosunCoAuthorLine(),
4268
+ ``,
4269
+ ] : []),
4199
4270
  `Original task description:`,
4200
4271
  task.description || "See task URL for details.",
4201
4272
  ].join("\n");
@@ -5101,6 +5172,58 @@ class TaskExecutor {
5101
5172
  /* best-effort upstream rebase */
5102
5173
  }
5103
5174
 
5175
+ // Additionally sync with origin/main (or DEFAULT_TARGET_BRANCH) so that
5176
+ // module branches continuously absorb upstream changes before each push.
5177
+ // This surfaces conflicts early — if a merge conflict occurs, we abort the
5178
+ // merge and push as-is; the NEXT agent working on this branch will encounter
5179
+ // the conflict immediately and resolve it inline (no separate conflict-fix task).
5180
+ try {
5181
+ const mainBranch =
5182
+ process.env.DEFAULT_TARGET_BRANCH ||
5183
+ process.env.VK_TARGET_BRANCH?.replace(/^origin\//, "") ||
5184
+ "main";
5185
+ const syncEnabled =
5186
+ (process.env.TASK_UPSTREAM_SYNC_MAIN ?? "true") !== "false";
5187
+ // Only sync main when base branch is NOT already main itself
5188
+ const isMainBase = baseInfo.branch === mainBranch || baseBranch === "main";
5189
+ if (syncEnabled && !isMainBase) {
5190
+ spawnSync("git", ["fetch", "origin", mainBranch, "--quiet"], {
5191
+ cwd: worktreePath,
5192
+ encoding: "utf8",
5193
+ timeout: 30_000,
5194
+ });
5195
+ const mainMergeResult = spawnSync(
5196
+ "git",
5197
+ ["merge", `origin/${mainBranch}`, "--no-edit", "--no-ff", "-X", "ours"],
5198
+ {
5199
+ cwd: worktreePath,
5200
+ encoding: "utf8",
5201
+ timeout: 60_000,
5202
+ },
5203
+ );
5204
+ if (mainMergeResult.status !== 0) {
5205
+ // Conflicts or error — abort and continue with push as-is.
5206
+ // The unresolved divergence will be visible to the next agent.
5207
+ console.warn(
5208
+ `${TAG} upstream main merge (origin/${mainBranch}) had conflicts on ` +
5209
+ `${branch} — aborting merge, pushing branch as-is. ` +
5210
+ `Next agent on this branch will resolve conflicts.`,
5211
+ );
5212
+ spawnSync("git", ["merge", "--abort"], {
5213
+ cwd: worktreePath,
5214
+ encoding: "utf8",
5215
+ timeout: 10_000,
5216
+ });
5217
+ } else {
5218
+ console.log(
5219
+ `${TAG} synced ${branch} with origin/${mainBranch} before push.`,
5220
+ );
5221
+ }
5222
+ }
5223
+ } catch {
5224
+ /* best-effort upstream main sync */
5225
+ }
5226
+
5104
5227
  const safety = evaluateBranchSafetyForPush(worktreePath, {
5105
5228
  baseBranch,
5106
5229
  remote: "origin",
package/ui/app.js CHANGED
@@ -255,25 +255,11 @@ function Header() {
255
255
  return html`
256
256
  <header class="app-header">
257
257
  <div class="app-header-left">
258
- <div class="app-header-brand">
259
- <div class="app-header-logo">
260
- <img src="logo.png" alt="Bosun" class="app-logo-img" />
261
- </div>
262
- <div class="app-header-titles">
263
- <div class="app-header-title">Bosun</div>
264
- ${subLabel
265
- ? html`<div class="app-header-subtitle">${subLabel}</div>`
266
- : null}
267
- ${navHint
268
- ? html`<div class="app-header-hint">${navHint}</div>`
269
- : null}
270
- </div>
271
- </div>
272
- </div>
273
- <div class="app-header-right">
274
258
  <div class="app-header-workspace">
275
259
  <${WorkspaceSwitcher} />
276
260
  </div>
261
+ </div>
262
+ <div class="app-header-right">
277
263
  <div class="header-actions">
278
264
  <div class="header-status">
279
265
  <div class="connection-pill ${connClass}">
@@ -10,6 +10,7 @@ import { signal } from "@preact/signals";
10
10
  import htm from "htm";
11
11
  import { apiFetch } from "../modules/api.js";
12
12
  import { haptic } from "../modules/telegram.js";
13
+ import { Modal } from "./shared.js";
13
14
 
14
15
  const html = htm.bind(h);
15
16
 
@@ -416,42 +417,34 @@ export function WorkspaceManager({ open, onClose }) {
416
417
  const loading = workspacesLoading.value;
417
418
 
418
419
  return html`
419
- <div class="ws-manager-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
420
- <div class="ws-manager-panel">
421
- <div class="ws-manager-header">
422
- <h2 class="ws-manager-title">Workspace Manager</h2>
423
- <div class="ws-manager-header-actions">
424
- <button
425
- class="ws-manager-btn ghost"
426
- onClick=${handleScan}
427
- disabled=${scanning}
428
- title="Scan disk for workspaces"
429
- >${scanning ? html`<${Spinner} /> Scanning…` : "🔍 Scan Disk"}</button>
430
- <button class="ws-manager-close-btn" onClick=${onClose} title="Close">✕</button>
431
- </div>
432
- </div>
420
+ <${Modal} title="Manage Workspaces" open=${open} onClose=${onClose}>
421
+ <div class="ws-manager-modal-toolbar">
422
+ <button
423
+ class="btn btn-ghost btn-sm"
424
+ onClick=${handleScan}
425
+ disabled=${scanning}
426
+ title="Scan disk for workspaces"
427
+ >${scanning ? "Scanning…" : "🔍 Scan Disk"}</button>
428
+ </div>
433
429
 
434
- <div class="ws-manager-body">
435
- ${loading && !wsList.length
436
- ? html`<div class="ws-manager-loading"><${Spinner} /> Loading workspaces…</div>`
437
- : null
438
- }
430
+ ${loading && !wsList.length
431
+ ? html`<div class="ws-manager-loading">Loading workspaces…</div>`
432
+ : null
433
+ }
439
434
 
440
- <div class="ws-manager-list">
441
- ${wsList.map((ws) => html`
442
- <${WorkspaceCard} key=${ws.id} ws=${ws} />
443
- `)}
444
- </div>
435
+ <div class="ws-manager-list">
436
+ ${wsList.map((ws) => html`
437
+ <${WorkspaceCard} key=${ws.id} ws=${ws} />
438
+ `)}
439
+ </div>
445
440
 
446
- ${!wsList.length && !loading
447
- ? html`<div class="ws-manager-empty-state">No workspaces found. Create one or scan disk.</div>`
448
- : null
449
- }
441
+ ${!wsList.length && !loading
442
+ ? html`<div class="ws-manager-empty-state">No workspaces found. Create one or scan disk.</div>`
443
+ : null
444
+ }
450
445
 
451
- <${AddWorkspaceForm} />
452
- </div>
453
- </div>
454
- </div>
446
+ <${AddWorkspaceForm} />
447
+ <//>
455
448
  `;
456
449
  }
457
450
 
@@ -111,6 +111,13 @@ export const SETTINGS_SCHEMA = [
111
111
  { key: "STALE_TASK_AGE_HOURS", label: "Stale Task Age", category: "kanban", type: "number", defaultVal: 3, min: 1, max: 168, unit: "hours", description: "Hours before an in-progress task with no activity is considered stale and eligible for recovery." },
112
112
  { key: "TASK_PLANNER_MODE", label: "Task Planner Mode", category: "kanban", type: "select", defaultVal: "kanban", options: ["kanban", "codex-sdk", "disabled"], description: "How the autonomous task planner operates. 'disabled' turns off automatic task generation." },
113
113
  { key: "TASK_PLANNER_DEDUP_HOURS", label: "Planner Dedup Window", category: "kanban", type: "number", defaultVal: 6, min: 1, max: 72, unit: "hours", description: "Hours to look back for duplicate task detection.", advanced: true },
114
+
115
+ // ── Branch Strategy ──────────────────────────────────────────────────
116
+ { key: "TASK_BRANCH_MODE", label: "Branch Mode", category: "branching", type: "select", defaultVal: "worktree", options: ["worktree", "direct"], description: "worktree: agents use isolated ve/<id>-<slug> sub-branches that PR into the module/base branch (default). direct: agents push directly onto the module branch — sequential, real-time conflict resolution.", restart: true },
117
+ { key: "TASK_BRANCH_AUTO_MODULE", label: "Auto Module Branch", category: "branching", type: "boolean", defaultVal: true, description: "Automatically detect origin/<module> as base_branch from conventional commit titles (e.g. feat(veid): → origin/veid). Enables parallel branch-per-module strategy." },
118
+ { key: "MODULE_BRANCH_PREFIX", label: "Module Branch Prefix", category: "branching", type: "string", defaultVal: "origin/", description: "Prefix prepended to module scope when building module branch refs (default: origin/).", advanced: true },
119
+ { key: "DEFAULT_TARGET_BRANCH", label: "Default Target Branch", category: "branching", type: "string", defaultVal: "main", description: "The main upstream branch agents sync with before pushing (e.g. main). Used for upstream sync and PR base detection." },
120
+ { key: "TASK_UPSTREAM_SYNC_MAIN", label: "Upstream Main Sync", category: "branching", type: "boolean", defaultVal: true, description: "Before each push, merge origin/main into the task branch to keep it continuously in sync with upstream. If a conflict occurs, it is aborted and the next agent on the branch resolves it." },
114
121
  { key: "BOSUN_PROMPT_PLANNER", label: "Planner Prompt Path", category: "advanced", type: "string", description: "Override the task planner prompt file path.", advanced: true },
115
122
 
116
123
  // ── Logging / Telemetry ──────────────────────────────────────