bosun 0.28.2 → 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/setup.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { createInterface } from "node:readline";
24
24
  import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
25
+ import { homedir } from "node:os";
25
26
  import { resolve, dirname, basename, relative, isAbsolute } from "node:path";
26
27
  import { execSync } from "node:child_process";
27
28
  import { execFileSync } from "node:child_process";
@@ -654,6 +655,18 @@ function detectRepoSlug(cwd) {
654
655
  }
655
656
  }
656
657
 
658
+ function detectRepoRemoteUrl(cwd) {
659
+ try {
660
+ return execSync("git remote get-url origin", {
661
+ encoding: "utf8",
662
+ cwd: cwd || process.cwd(),
663
+ stdio: ["pipe", "pipe", "ignore"],
664
+ }).trim();
665
+ } catch {
666
+ return "";
667
+ }
668
+ }
669
+
657
670
  function detectRepoRoot(cwd) {
658
671
  try {
659
672
  return execSync("git rev-parse --show-toplevel", {
@@ -679,6 +692,36 @@ function detectProjectName(repoRoot) {
679
692
  return basename(repoRoot);
680
693
  }
681
694
 
695
+ function hasSshKeyMaterial() {
696
+ if (process.env.SSH_AUTH_SOCK) return true;
697
+ const home = homedir();
698
+ if (!home) return false;
699
+ const candidates = [
700
+ ".ssh/id_rsa.pub",
701
+ ".ssh/id_ed25519.pub",
702
+ ".ssh/id_ecdsa.pub",
703
+ ".ssh/id_dsa.pub",
704
+ ];
705
+ return candidates.some((rel) => existsSync(resolve(home, rel)));
706
+ }
707
+
708
+ function isSshGitUrl(value) {
709
+ const text = String(value || "").trim();
710
+ if (!text) return false;
711
+ return text.startsWith("git@") || text.startsWith("ssh://");
712
+ }
713
+
714
+ function buildDefaultGitUrl(slug, repoRoot) {
715
+ const normalizedSlug = String(slug || "").trim();
716
+ if (!normalizedSlug) return "";
717
+ const remote = detectRepoRemoteUrl(repoRoot);
718
+ if (remote) return remote;
719
+ const preferSsh = hasSshKeyMaterial();
720
+ return preferSsh
721
+ ? `git@github.com:${normalizedSlug}.git`
722
+ : `https://github.com/${normalizedSlug}.git`;
723
+ }
724
+
682
725
  function formatModelVariant(profile) {
683
726
  if (!profile?.model && !profile?.variant) return "";
684
727
  if (profile?.model && profile?.variant) {
@@ -1584,7 +1627,13 @@ function buildDefaultWritableRoots(repoRoot) {
1584
1627
  if (parent && parent !== repo) roots.add(parent);
1585
1628
  roots.add(repo);
1586
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"));
1587
1634
  }
1635
+ // /tmp needed for sandbox temp files, pip installs, etc.
1636
+ roots.add("/tmp");
1588
1637
  return Array.from(roots).join(",");
1589
1638
  }
1590
1639
 
@@ -2222,10 +2271,27 @@ async function main() {
2222
2271
  let repoIdx = 0;
2223
2272
 
2224
2273
  while (addMoreRepos) {
2225
- const repoUrl = await prompt.ask(
2274
+ let repoUrl = await prompt.ask(
2226
2275
  ` Repo ${repoIdx + 1} — git URL (SSH or HTTPS)`,
2227
- repoIdx === 0 ? (env.GITHUB_REPO ? `git@github.com:${env.GITHUB_REPO}.git` : "") : "",
2276
+ repoIdx === 0
2277
+ ? buildDefaultGitUrl(env.GITHUB_REPO || slug, repoRoot)
2278
+ : "",
2228
2279
  );
2280
+ if (repoUrl && isSshGitUrl(repoUrl) && !hasSshKeyMaterial()) {
2281
+ warn(
2282
+ "SSH URL detected but no SSH agent/keys found. Cloning may fail unless SSH is configured.",
2283
+ );
2284
+ const switchToHttps = await prompt.confirm(
2285
+ "Use HTTPS URL instead?",
2286
+ true,
2287
+ );
2288
+ if (switchToHttps) {
2289
+ const parsedSlug = parseRepoSlugFromUrl(repoUrl);
2290
+ if (parsedSlug) {
2291
+ repoUrl = `https://github.com/${parsedSlug}.git`;
2292
+ }
2293
+ }
2294
+ }
2229
2295
  const parsedSlug = parseRepoSlugFromUrl(repoUrl);
2230
2296
  const parsedRepoName = parsedSlug ? parsedSlug.split("/")[1] : "";
2231
2297
  const defaultNameFromUrl = repoUrl
@@ -2872,11 +2938,12 @@ async function main() {
2872
2938
  if (ghStatus.ok) break;
2873
2939
 
2874
2940
  warn(
2875
- "GitHub auth is required to auto-detect projects, create boards, and sync issues.",
2941
+ `GitHub auth is required to auto-detect projects, create boards, and sync issues. ${ghStatus.reason || ""}`.trim(),
2876
2942
  );
2877
2943
  info(
2878
2944
  "If you do not plan to use GitHub as the task manager, pick Internal, Jira, or Vibe-Kanban.",
2879
2945
  );
2946
+ info("Authenticate with GitHub using: gh auth login");
2880
2947
  const ghActionIdx = await prompt.choose(
2881
2948
  "How do you want to proceed?",
2882
2949
  [
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/telegram-bot.mjs CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  getWorktreeStats,
60
60
  } from "./worktree-manager.mjs";
61
61
  import { loadExecutorConfig } from "./config.mjs";
62
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
62
63
  import {
63
64
  getTelegramUiUrl,
64
65
  startTelegramUiServer,
@@ -2701,7 +2702,7 @@ const COMMANDS = {
2701
2702
  },
2702
2703
  "/plan": {
2703
2704
  handler: cmdPlan,
2704
- desc: "Trigger task planner: /plan [count] (default 5)",
2705
+ desc: "Trigger task planner: /plan [count] [prompt] (e.g. /plan 5 fix auth bugs)",
2705
2706
  },
2706
2707
  "/cleanup": {
2707
2708
  handler: cmdCleanupMerged,
@@ -3237,9 +3238,24 @@ const UI_INPUT_HANDLERS = {
3237
3238
  buildCommand: (input) => `/shell ${input}`,
3238
3239
  },
3239
3240
  plan_count: {
3240
- prompt: "How many tasks should the planner generate?",
3241
+ prompt: "How many tasks should the planner generate? (e.g. 5)",
3241
3242
  buildCommand: (input) => `/plan ${input}`,
3242
3243
  },
3244
+ plan_prompt: {
3245
+ prompt:
3246
+ "Describe what you want the planner to focus on.\n" +
3247
+ "You can prefix with a count: e.g. '10 fix auth bugs and add tests'\n" +
3248
+ "Or just a topic: 'improve error handling across API layer'",
3249
+ buildCommand: (input) => {
3250
+ const trimmed = input.trim();
3251
+ const firstWord = trimmed.split(/\s+/)[0];
3252
+ const maybeCount = parseInt(firstWord, 10);
3253
+ if (Number.isFinite(maybeCount) && maybeCount > 0) {
3254
+ return `/plan ${trimmed}`;
3255
+ }
3256
+ return `/plan 5 ${trimmed}`;
3257
+ },
3258
+ },
3243
3259
  starttask: {
3244
3260
  prompt:
3245
3261
  "Enter the task ID to start manually.\nNext you'll pick executor → SDK → model.",
@@ -4418,7 +4434,10 @@ Object.assign(UI_SCREENS, {
4418
4434
  uiButton("Plan 5", uiCmdAction("/plan 5")),
4419
4435
  uiButton("Plan 10", uiCmdAction("/plan 10")),
4420
4436
  ],
4421
- [uiButton("Custom Count", uiInputAction("plan_count"))],
4437
+ [
4438
+ uiButton("Custom Count", uiInputAction("plan_count")),
4439
+ uiButton("With Prompt", uiInputAction("plan_prompt")),
4440
+ ],
4422
4441
  uiNavRow("tasks"),
4423
4442
  ]),
4424
4443
  },
@@ -7097,11 +7116,26 @@ async function cmdPlan(chatId, args) {
7097
7116
  return;
7098
7117
  }
7099
7118
 
7100
- // Parse optional task count: /plan 5 or /plan 10
7101
- const parsed = parseInt(args?.trim(), 10);
7102
- const taskCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
7119
+ // Parse optional task count and/or free-form prompt:
7120
+ // /plan → 5 tasks, no prompt
7121
+ // /plan 10 → 10 tasks, no prompt
7122
+ // /plan fix auth → 5 tasks, userPrompt="fix auth"
7123
+ // /plan 10 fix auth → 10 tasks, userPrompt="fix auth"
7124
+ const rawArgs = (args || "").trim();
7125
+ const firstToken = rawArgs.split(/\s+/)[0];
7126
+ const parsedCount = parseInt(firstToken, 10);
7127
+ let taskCount = 5;
7128
+ let userPrompt;
7129
+ if (Number.isFinite(parsedCount) && parsedCount > 0) {
7130
+ taskCount = parsedCount;
7131
+ const remainder = rawArgs.slice(firstToken.length).trim();
7132
+ if (remainder) userPrompt = remainder;
7133
+ } else if (rawArgs) {
7134
+ userPrompt = rawArgs;
7135
+ }
7103
7136
 
7104
- await sendReply(chatId, `📋 Triggering task planner (${taskCount} tasks)...`);
7137
+ const promptSuffix = userPrompt ? ` — "${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}"` : "";
7138
+ await sendReply(chatId, `📋 Triggering task planner (${taskCount} tasks${promptSuffix})...`);
7105
7139
 
7106
7140
  try {
7107
7141
  const result = await _triggerTaskPlanner(
@@ -7109,6 +7143,7 @@ async function cmdPlan(chatId, args) {
7109
7143
  { source: "telegram /plan command" },
7110
7144
  {
7111
7145
  taskCount,
7146
+ userPrompt,
7112
7147
  notify: false,
7113
7148
  preferredMode: "codex-sdk",
7114
7149
  allowCodexWhenDisabled: true,
@@ -7398,7 +7433,9 @@ async function cmdShell(chatId, shellArgs) {
7398
7433
 
7399
7434
  function runPwsh(psScript, timeoutMs = 15000) {
7400
7435
  const isWin = process.platform === "win32";
7401
- const pwsh = isWin ? "powershell.exe" : "pwsh";
7436
+ const pwsh = isWin
7437
+ ? "powershell.exe"
7438
+ : resolvePwshRuntime({ preferBundled: true }).command;
7402
7439
  const script = `& { ${psScript} }`;
7403
7440
  const result = spawnSync(pwsh, ["-NoProfile", "-Command", script], {
7404
7441
  cwd: repoRoot,
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 ──────────────────────────────────────
@@ -8,6 +8,7 @@
8
8
  box-sizing: border-box;
9
9
  margin: 0;
10
10
  padding: 0;
11
+ min-width: 0;
11
12
  }
12
13
 
13
14
  html,
@@ -21,8 +22,6 @@ body {
21
22
  line-height: 1.5;
22
23
  color: var(--text-primary);
23
24
  background: var(--bg-primary);
24
- background-image: var(--gradient-mesh);
25
- background-attachment: fixed;
26
25
  -webkit-font-smoothing: antialiased;
27
26
  -moz-osx-font-smoothing: grayscale;
28
27
  -webkit-tap-highlight-color: transparent;
@@ -34,8 +33,6 @@ body {
34
33
  }
35
34
 
36
35
  #app {
37
- /* height: 100% (not min-height) anchors the flex tree so .main-content gets
38
- a real bounded height to scroll within instead of growing indefinitely */
39
36
  height: 100%;
40
37
  overflow: hidden;
41
38
  display: flex;
@@ -44,32 +41,10 @@ body {
44
41
  z-index: 1;
45
42
  }
46
43
 
44
+ /* Remove body glow pseudo-elements — they cause visual noise */
47
45
  body::before,
48
46
  body::after {
49
- content: "";
50
- position: fixed;
51
- pointer-events: none;
52
- z-index: 0;
53
- inset: auto;
54
- opacity: 0.45;
55
- }
56
-
57
- body::before {
58
- top: -20%;
59
- left: -10%;
60
- right: -10%;
61
- height: 55%;
62
- background: var(--gradient-hero);
63
- filter: blur(12px);
64
- }
65
-
66
- body::after {
67
- bottom: -25%;
68
- left: -20%;
69
- right: -20%;
70
- height: 60%;
71
- background: var(--gradient-radial-glow);
72
- filter: blur(18px);
47
+ display: none;
73
48
  }
74
49
 
75
50
  /* Selection styling */